@geminixiang/mama 0.1.10 → 0.2.0-beta.1

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 (151) hide show
  1. package/README.md +80 -23
  2. package/dist/adapter.d.ts +11 -9
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +2 -2
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +33 -21
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +20 -13
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/slack/bot.d.ts +13 -4
  13. package/dist/adapters/slack/bot.d.ts.map +1 -1
  14. package/dist/adapters/slack/bot.js +98 -43
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/context.d.ts.map +1 -1
  17. package/dist/adapters/slack/context.js +25 -20
  18. package/dist/adapters/slack/context.js.map +1 -1
  19. package/dist/adapters/telegram/bot.d.ts +4 -2
  20. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  21. package/dist/adapters/telegram/bot.js +143 -58
  22. package/dist/adapters/telegram/bot.js.map +1 -1
  23. package/dist/adapters/telegram/context.d.ts +1 -1
  24. package/dist/adapters/telegram/context.d.ts.map +1 -1
  25. package/dist/adapters/telegram/context.js +124 -29
  26. package/dist/adapters/telegram/context.js.map +1 -1
  27. package/dist/agent.d.ts +7 -4
  28. package/dist/agent.d.ts.map +1 -1
  29. package/dist/agent.js +303 -89
  30. package/dist/agent.js.map +1 -1
  31. package/dist/bindings.d.ts +63 -0
  32. package/dist/bindings.d.ts.map +1 -0
  33. package/dist/bindings.js +94 -0
  34. package/dist/bindings.js.map +1 -0
  35. package/dist/config.d.ts +34 -4
  36. package/dist/config.d.ts.map +1 -1
  37. package/dist/config.js +98 -38
  38. package/dist/config.js.map +1 -1
  39. package/dist/context.d.ts +8 -6
  40. package/dist/context.d.ts.map +1 -1
  41. package/dist/context.js +23 -14
  42. package/dist/context.js.map +1 -1
  43. package/dist/events.d.ts +4 -0
  44. package/dist/events.d.ts.map +1 -1
  45. package/dist/events.js +20 -5
  46. package/dist/events.js.map +1 -1
  47. package/dist/execution-resolver.d.ts +20 -0
  48. package/dist/execution-resolver.d.ts.map +1 -0
  49. package/dist/execution-resolver.js +51 -0
  50. package/dist/execution-resolver.js.map +1 -0
  51. package/dist/instrument.d.ts +2 -0
  52. package/dist/instrument.d.ts.map +1 -0
  53. package/dist/instrument.js +14 -0
  54. package/dist/instrument.js.map +1 -0
  55. package/dist/link-server.d.ts +16 -0
  56. package/dist/link-server.d.ts.map +1 -0
  57. package/dist/link-server.js +839 -0
  58. package/dist/link-server.js.map +1 -0
  59. package/dist/link-token.d.ts +32 -0
  60. package/dist/link-token.d.ts.map +1 -0
  61. package/dist/link-token.js +68 -0
  62. package/dist/link-token.js.map +1 -0
  63. package/dist/log.d.ts +3 -2
  64. package/dist/log.d.ts.map +1 -1
  65. package/dist/log.js +10 -9
  66. package/dist/log.js.map +1 -1
  67. package/dist/login.d.ts +29 -0
  68. package/dist/login.d.ts.map +1 -0
  69. package/dist/login.js +164 -0
  70. package/dist/login.js.map +1 -0
  71. package/dist/main.d.ts +1 -1
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +322 -82
  74. package/dist/main.js.map +1 -1
  75. package/dist/provisioner.d.ts +93 -0
  76. package/dist/provisioner.d.ts.map +1 -0
  77. package/dist/provisioner.js +336 -0
  78. package/dist/provisioner.js.map +1 -0
  79. package/dist/sandbox/container.d.ts +15 -0
  80. package/dist/sandbox/container.d.ts.map +1 -0
  81. package/dist/sandbox/container.js +122 -0
  82. package/dist/sandbox/container.js.map +1 -0
  83. package/dist/sandbox/errors.d.ts +6 -0
  84. package/dist/sandbox/errors.d.ts.map +1 -0
  85. package/dist/sandbox/errors.js +11 -0
  86. package/dist/sandbox/errors.js.map +1 -0
  87. package/dist/sandbox/firecracker.d.ts +16 -0
  88. package/dist/sandbox/firecracker.d.ts.map +1 -0
  89. package/dist/sandbox/firecracker.js +206 -0
  90. package/dist/sandbox/firecracker.js.map +1 -0
  91. package/dist/sandbox/host.d.ts +12 -0
  92. package/dist/sandbox/host.d.ts.map +1 -0
  93. package/dist/sandbox/host.js +89 -0
  94. package/dist/sandbox/host.js.map +1 -0
  95. package/dist/sandbox/image.d.ts +5 -0
  96. package/dist/sandbox/image.d.ts.map +1 -0
  97. package/dist/sandbox/image.js +30 -0
  98. package/dist/sandbox/image.js.map +1 -0
  99. package/dist/sandbox/index.d.ts +20 -0
  100. package/dist/sandbox/index.d.ts.map +1 -0
  101. package/dist/sandbox/index.js +51 -0
  102. package/dist/sandbox/index.js.map +1 -0
  103. package/dist/sandbox/types.d.ts +51 -0
  104. package/dist/sandbox/types.d.ts.map +1 -0
  105. package/dist/sandbox/types.js +2 -0
  106. package/dist/sandbox/types.js.map +1 -0
  107. package/dist/sandbox/utils.d.ts +4 -0
  108. package/dist/sandbox/utils.d.ts.map +1 -0
  109. package/dist/sandbox/utils.js +51 -0
  110. package/dist/sandbox/utils.js.map +1 -0
  111. package/dist/sandbox.d.ts +1 -39
  112. package/dist/sandbox.d.ts.map +1 -1
  113. package/dist/sandbox.js +1 -286
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/sentry.d.ts +31 -0
  116. package/dist/sentry.d.ts.map +1 -0
  117. package/dist/sentry.js +205 -0
  118. package/dist/sentry.js.map +1 -0
  119. package/dist/session-store.d.ts +72 -0
  120. package/dist/session-store.d.ts.map +1 -0
  121. package/dist/session-store.js +186 -0
  122. package/dist/session-store.js.map +1 -0
  123. package/dist/store.d.ts +1 -1
  124. package/dist/store.d.ts.map +1 -1
  125. package/dist/store.js +8 -8
  126. package/dist/store.js.map +1 -1
  127. package/dist/tools/event.d.ts +21 -0
  128. package/dist/tools/event.d.ts.map +1 -0
  129. package/dist/tools/event.js +103 -0
  130. package/dist/tools/event.js.map +1 -0
  131. package/dist/tools/index.d.ts +6 -1
  132. package/dist/tools/index.d.ts.map +1 -1
  133. package/dist/tools/index.js +5 -1
  134. package/dist/tools/index.js.map +1 -1
  135. package/dist/ui-copy.d.ts +11 -0
  136. package/dist/ui-copy.d.ts.map +1 -0
  137. package/dist/ui-copy.js +33 -0
  138. package/dist/ui-copy.js.map +1 -0
  139. package/dist/vault-routing.d.ts +10 -0
  140. package/dist/vault-routing.d.ts.map +1 -0
  141. package/dist/vault-routing.js +58 -0
  142. package/dist/vault-routing.js.map +1 -0
  143. package/dist/vault.d.ts +106 -0
  144. package/dist/vault.d.ts.map +1 -0
  145. package/dist/vault.js +389 -0
  146. package/dist/vault.js.map +1 -0
  147. package/dist/vault.test.d.ts +2 -0
  148. package/dist/vault.test.d.ts.map +1 -0
  149. package/dist/vault.test.js +67 -0
  150. package/dist/vault.test.js.map +1 -0
  151. package/package.json +13 -11
package/dist/main.js CHANGED
@@ -1,17 +1,31 @@
1
1
  #!/usr/bin/env node
2
+ import "./instrument.js";
2
3
  import { join, resolve } from "path";
3
- import { readFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { mkdirSync, readFileSync, statSync } from "fs";
4
6
  import { fileURLToPath } from "url";
5
7
  import { dirname, join as pathJoin } from "path";
6
8
  import { DiscordBot } from "./adapters/discord/index.js";
7
9
  import { TelegramBot } from "./adapters/telegram/index.js";
8
10
  import { SlackBot as SlackBotClass } from "./adapters/slack/index.js";
9
11
  import { createRunner } from "./agent.js";
12
+ import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, } from "./session-store.js";
10
13
  import { downloadChannel } from "./download.js";
11
14
  import { createEventsWatcher } from "./events.js";
12
15
  import * as log from "./log.js";
13
- import { parseSandboxArg, validateSandbox } from "./sandbox.js";
16
+ import { FileUserBindingStore } from "./bindings.js";
17
+ import { startLinkServer } from "./link-server.js";
18
+ import { parseLoginCommand } from "./login.js";
19
+ import { InMemoryLinkTokenStore } from "./link-token.js";
20
+ import { DockerContainerManager } from "./provisioner.js";
21
+ import { SandboxError, parseSandboxArg, validateSandbox } from "./sandbox.js";
22
+ import { formatNothingRunning, formatStopping } from "./ui-copy.js";
23
+ import { FileVaultManager } from "./vault.js";
24
+ import { ensureSettingsFile } from "./config.js";
25
+ import { createManagedVaultEntry, ensureSandboxVaultEntry, resolveActorVaultKey, } from "./vault-routing.js";
26
+ import { addLifecycleBreadcrumb, applyRunScope } from "./sentry.js";
14
27
  import { ChannelStore } from "./store.js";
28
+ import * as Sentry from "@sentry/node";
15
29
  // ============================================================================
16
30
  // Config
17
31
  // ============================================================================
@@ -39,10 +53,23 @@ const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
39
53
  const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
40
54
  const MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;
41
55
  const MOM_DISCORD_BOT_TOKEN = process.env.MOM_DISCORD_BOT_TOKEN;
56
+ /** Base URL of the web login portal, e.g. https://platform.trygemini.xyz */
57
+ const MOM_LINK_URL = process.env.MOM_LINK_URL;
58
+ /**
59
+ * Port for the link callback HTTP server.
60
+ * Defaults to 8181 when MOM_LINK_URL is set (behind a reverse proxy).
61
+ * If neither is set, the server is not started.
62
+ */
63
+ const MOM_LINK_PORT = process.env.MOM_LINK_PORT
64
+ ? parseInt(process.env.MOM_LINK_PORT, 10)
65
+ : MOM_LINK_URL
66
+ ? 8181
67
+ : undefined;
42
68
  function parseArgs() {
43
69
  const args = process.argv.slice(2);
44
70
  let sandbox = { type: "host" };
45
71
  let workingDir;
72
+ let stateDirArg;
46
73
  let downloadChannelId;
47
74
  let showVersion = false;
48
75
  for (let i = 0; i < args.length; i++) {
@@ -56,6 +83,12 @@ function parseArgs() {
56
83
  else if (arg === "--sandbox") {
57
84
  sandbox = parseSandboxArg(args[++i] || "");
58
85
  }
86
+ else if (arg.startsWith("--state-dir=")) {
87
+ stateDirArg = arg.slice("--state-dir=".length);
88
+ }
89
+ else if (arg === "--state-dir") {
90
+ stateDirArg = args[++i];
91
+ }
59
92
  else if (arg.startsWith("--download=")) {
60
93
  downloadChannelId = arg.slice("--download=".length);
61
94
  }
@@ -68,12 +101,66 @@ function parseArgs() {
68
101
  }
69
102
  return {
70
103
  workingDir: workingDir ? resolve(workingDir) : undefined,
104
+ stateDir: stateDirArg ? resolve(stateDirArg) : undefined,
71
105
  sandbox,
72
106
  downloadChannel: downloadChannelId,
73
107
  showVersion,
74
108
  };
75
109
  }
76
- const parsedArgs = parseArgs();
110
+ const WORLD_WRITABLE_MODE = 0o002;
111
+ /**
112
+ * Create stateDir if missing and refuse to use it if another local user could
113
+ * tamper with its contents. stateDir holds vaults, bindings, and settings —
114
+ * a world-writable or foreign-owned directory there would let a local attacker
115
+ * swap in credentials or change routing.
116
+ */
117
+ function ensureSecureStateDir(path) {
118
+ let stat;
119
+ try {
120
+ stat = statSync(path);
121
+ }
122
+ catch (err) {
123
+ const code = err.code;
124
+ if (code === "ENOENT") {
125
+ mkdirSync(path, { recursive: true, mode: 0o700 });
126
+ return;
127
+ }
128
+ console.error(`Error: cannot access --state-dir ${path}: ${err.message}`);
129
+ process.exit(1);
130
+ }
131
+ if (!stat.isDirectory()) {
132
+ console.error(`Error: --state-dir ${path} exists but is not a directory`);
133
+ process.exit(1);
134
+ }
135
+ if (stat.mode & WORLD_WRITABLE_MODE) {
136
+ console.error(`Error: --state-dir ${path} is world-writable (mode ${(stat.mode & 0o777).toString(8)}). ` +
137
+ `Credentials stored there would be exposed to other local users. ` +
138
+ `Fix with: chmod 0700 ${path}`);
139
+ process.exit(1);
140
+ }
141
+ const euid = typeof process.geteuid === "function" ? process.geteuid() : undefined;
142
+ if (euid !== undefined && stat.uid !== euid) {
143
+ console.error(`Error: --state-dir ${path} is owned by uid ${stat.uid} but mama is running as uid ${euid}. ` +
144
+ `Run mama as the directory owner or point --state-dir at a directory you own.`);
145
+ process.exit(1);
146
+ }
147
+ }
148
+ function handleStartupError(error) {
149
+ if (error instanceof SandboxError) {
150
+ for (const line of error.formatForCli()) {
151
+ console.error(line);
152
+ }
153
+ process.exit(1);
154
+ }
155
+ throw error;
156
+ }
157
+ let parsedArgs;
158
+ try {
159
+ parsedArgs = parseArgs();
160
+ }
161
+ catch (error) {
162
+ handleStartupError(error);
163
+ }
77
164
  // Handle --version
78
165
  if (parsedArgs.showVersion) {
79
166
  console.log(getVersion());
@@ -90,11 +177,27 @@ if (parsedArgs.downloadChannel) {
90
177
  }
91
178
  // Normal bot mode - require working dir
92
179
  if (!parsedArgs.workingDir) {
93
- console.error("Usage: mama [--sandbox=host|docker:<name>|firecracker:<vm-id>:<host-path>] <working-directory>");
180
+ console.error("Usage: mama [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] [--state-dir=<path>] <working-directory>");
94
181
  console.error(" mama --download <channel-id>");
95
182
  process.exit(1);
96
183
  }
97
184
  const { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };
185
+ // stateDir holds operator-managed files (vaults, settings, bindings).
186
+ // Defaults to ~/.mama to keep secrets outside the project workspace mounted into sandboxes.
187
+ const stateDir = parsedArgs.stateDir ?? join(homedir(), ".mama");
188
+ ensureSecureStateDir(stateDir);
189
+ // Share stateDir with instrument.ts (for Sentry config loading)
190
+ process.env.MAMA_STATE_DIR = stateDir;
191
+ // Ensure settings.json exists; create a template if first run.
192
+ const { created: settingsCreated, config: agentSettings } = ensureSettingsFile(stateDir);
193
+ if (settingsCreated) {
194
+ console.log(`Created default settings: ${join(stateDir, "settings.json")}`);
195
+ console.log("Review and update provider/model before starting.");
196
+ }
197
+ if (!agentSettings.provider || !agentSettings.model) {
198
+ console.error(`Error: 'provider' and 'model' must be set in ${join(stateDir, "settings.json")}`);
199
+ process.exit(1);
200
+ }
98
201
  // Validate platform tokens
99
202
  const hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);
100
203
  const hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;
@@ -106,36 +209,74 @@ if (!hasSlack && !hasTelegram && !hasDiscord) {
106
209
  " Discord: MOM_DISCORD_BOT_TOKEN");
107
210
  process.exit(1);
108
211
  }
109
- await validateSandbox(sandbox);
110
- const channelStates = new Map();
111
- /**
112
- * Maps "channel:botReplyTs" → sessionKey.
113
- * When the bot posts a top-level reply, the Slack thread anchors to that ts.
114
- * Users replying in that thread will have thread_ts = botReplyTs, which differs
115
- * from the original sessionKey (channel:userMessageTs). This alias map lets
116
- * stop commands resolve the correct session even when the ts doesn't match.
117
- */
118
- const threadAliases = new Map();
212
+ try {
213
+ await validateSandbox(sandbox);
214
+ }
215
+ catch (error) {
216
+ handleStartupError(error);
217
+ }
218
+ const vaultManager = new FileVaultManager(stateDir);
219
+ if (vaultManager.isEnabled()) {
220
+ console.log(sandbox.type === "container"
221
+ ? " Vault system enabled. Shared container vault active."
222
+ : " Vault system enabled. Per-user credential routing active.");
223
+ }
224
+ const bindingStore = new FileUserBindingStore(stateDir);
225
+ if (bindingStore.isEnabled()) {
226
+ console.log(sandbox.type === "container"
227
+ ? " Binding store enabled. Shared container mode ignores per-user vault bindings."
228
+ : " Binding store enabled. Platform user → vault routing active.");
229
+ }
230
+ const provisioner = sandbox.type === "image" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;
231
+ const linkTokenStore = new InMemoryLinkTokenStore();
232
+ // Purge expired link tokens every 5 minutes
233
+ setInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();
234
+ const conversationStates = new Map();
119
235
  /** Track in-flight runs for graceful shutdown */
120
236
  const inFlightRuns = new Set();
121
237
  /** Flag to stop accepting new events during shutdown */
122
238
  let isShuttingDown = false;
123
239
  /** Maximum number of cached sessions */
124
240
  const MAX_SESSIONS = 500;
125
- /** Idle timeout before a non-running session can be evicted (1 hour) */
126
- const IDLE_TIMEOUT_MS = 3600000;
127
- async function getState(channelId, sessionKey) {
128
- const key = sessionKey ?? channelId;
129
- let state = channelStates.get(key);
241
+ /** Idle timeout before a non-running session can be evicted (10 minutes) */
242
+ const IDLE_TIMEOUT_MS = 600000;
243
+ if (provisioner) {
244
+ await provisioner.reconcile();
245
+ await provisioner.stopIdle(IDLE_TIMEOUT_MS);
246
+ }
247
+ // Stop idle containers every hour (same cadence as session eviction)
248
+ if (provisioner) {
249
+ setInterval(() => provisioner.stopIdle(IDLE_TIMEOUT_MS), IDLE_TIMEOUT_MS).unref();
250
+ }
251
+ function normalizeLoginBaseUrl() {
252
+ if (MOM_LINK_URL) {
253
+ return MOM_LINK_URL.replace(/\/+$/, "");
254
+ }
255
+ if (MOM_LINK_PORT) {
256
+ return `http://localhost:${MOM_LINK_PORT}`;
257
+ }
258
+ return undefined;
259
+ }
260
+ function ensureLoginVault(platform, platformUserId) {
261
+ const vaultId = resolveActorVaultKey(sandbox, vaultManager, bindingStore, platform, platformUserId);
262
+ ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);
263
+ if (sandbox.type !== "image" && sandbox.type !== "container") {
264
+ vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId, false));
265
+ }
266
+ return vaultId;
267
+ }
268
+ async function getState(conversationId, sessionKey) {
269
+ const key = sessionKey ?? conversationId;
270
+ let state = conversationStates.get(key);
130
271
  if (!state) {
131
- const channelDir = join(workingDir, channelId);
272
+ const conversationDir = join(workingDir, conversationId);
132
273
  state = {
133
274
  running: false,
134
- runner: await createRunner(sandbox, key, channelId, channelDir, workingDir),
275
+ runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner, stateDir),
135
276
  stopRequested: false,
136
277
  lastAccessedAt: Date.now(),
137
278
  };
138
- channelStates.set(key, state);
279
+ conversationStates.set(key, state);
139
280
  }
140
281
  else {
141
282
  state.lastAccessedAt = Date.now();
@@ -143,37 +284,27 @@ async function getState(channelId, sessionKey) {
143
284
  return state;
144
285
  }
145
286
  /**
146
- * Evict idle sessions from channelStates to bound memory usage.
287
+ * Evict idle sessions from conversationStates to bound memory usage.
147
288
  * Called after each handleEvent completes.
148
289
  */
149
290
  function evictIdleSessions() {
150
291
  const now = Date.now();
151
- for (const [key, state] of channelStates) {
292
+ for (const [key, state] of conversationStates) {
152
293
  if (!state.running && now - state.lastAccessedAt > IDLE_TIMEOUT_MS) {
153
- channelStates.delete(key);
154
- // Clean up aliases pointing to this session
155
- for (const [alias, target] of threadAliases) {
156
- if (target === key)
157
- threadAliases.delete(alias);
158
- }
294
+ conversationStates.delete(key);
159
295
  }
160
296
  }
161
- if (channelStates.size > MAX_SESSIONS) {
297
+ if (conversationStates.size > MAX_SESSIONS) {
162
298
  const idleSessions = [];
163
- for (const [key, state] of channelStates) {
299
+ for (const [key, state] of conversationStates) {
164
300
  if (!state.running) {
165
301
  idleSessions.push({ key, lastAccessedAt: state.lastAccessedAt });
166
302
  }
167
303
  }
168
304
  idleSessions.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
169
- const toEvict = channelStates.size - MAX_SESSIONS;
305
+ const toEvict = conversationStates.size - MAX_SESSIONS;
170
306
  for (let i = 0; i < toEvict && i < idleSessions.length; i++) {
171
- const evictedKey = idleSessions[i].key;
172
- channelStates.delete(evictedKey);
173
- for (const [alias, target] of threadAliases) {
174
- if (target === evictedKey)
175
- threadAliases.delete(alias);
176
- }
307
+ conversationStates.delete(idleSessions[i].key);
177
308
  }
178
309
  }
179
310
  }
@@ -182,12 +313,12 @@ function evictIdleSessions() {
182
313
  // ============================================================================
183
314
  const handler = {
184
315
  isRunning(sessionKey) {
185
- const state = channelStates.get(sessionKey);
316
+ const state = conversationStates.get(sessionKey);
186
317
  return !!state?.running;
187
318
  },
188
319
  getRunningSessions() {
189
320
  const sessions = [];
190
- for (const [sessionKey, state] of channelStates) {
321
+ for (const [sessionKey, state] of conversationStates) {
191
322
  if (state.running && state.startedAt) {
192
323
  // Get current step from runner
193
324
  const currentStep = state.runner.getCurrentStep();
@@ -201,20 +332,20 @@ const handler = {
201
332
  }
202
333
  return sessions;
203
334
  },
204
- async handleStop(sessionKey, channelId, bot) {
205
- const state = channelStates.get(sessionKey);
335
+ async handleStop(sessionKey, conversationId, bot) {
336
+ const state = conversationStates.get(sessionKey);
206
337
  if (state?.running) {
207
338
  state.stopRequested = true;
208
339
  state.runner.abort();
209
- const ts = await bot.postMessage(channelId, "_Stopping..._");
340
+ const ts = await bot.postMessage(conversationId, formatStopping(bot));
210
341
  state.stopMessageTs = ts;
211
342
  }
212
343
  else {
213
- await bot.postMessage(channelId, "_Nothing running_");
344
+ await bot.postMessage(conversationId, formatNothingRunning(bot));
214
345
  }
215
346
  },
216
347
  forceStop(sessionKey) {
217
- const state = channelStates.get(sessionKey);
348
+ const state = conversationStates.get(sessionKey);
218
349
  if (state?.running) {
219
350
  log.logInfo(`[Force Stop] Force stopping session: ${sessionKey}`);
220
351
  state.stopRequested = true;
@@ -222,55 +353,153 @@ const handler = {
222
353
  state.running = false;
223
354
  }
224
355
  },
225
- resolveSessionKey(rawKey) {
226
- return threadAliases.get(rawKey) ?? rawKey;
356
+ async handleNew(sessionKey, conversationId, bot) {
357
+ const state = conversationStates.get(sessionKey);
358
+ if (state?.running) {
359
+ state.stopRequested = true;
360
+ state.runner.abort();
361
+ }
362
+ // Channel sessions rotate via current pointer. Thread sessions reset in place.
363
+ const conversationDir = join(workingDir, conversationId);
364
+ if (sessionKey.includes(":")) {
365
+ createManagedSessionFileAtPath(getThreadSessionFile(conversationDir, sessionKey), conversationDir);
366
+ }
367
+ else {
368
+ createManagedSessionFile(getChannelSessionDir(conversationDir), conversationDir);
369
+ }
370
+ // Remove from in-memory cache
371
+ conversationStates.delete(sessionKey);
372
+ log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);
373
+ await bot.postMessage(conversationId, "Conversation reset. Send a new message to start fresh.");
227
374
  },
228
- registerThreadAlias(aliasKey, sessionKey) {
229
- threadAliases.set(aliasKey, sessionKey);
375
+ async handleLogin(platform, platformUserId, conversationId, bot, commandText, isPrivateConversation) {
376
+ const parsed = parseLoginCommand(commandText);
377
+ if (!parsed) {
378
+ return;
379
+ }
380
+ if (!isPrivateConversation) {
381
+ await bot.postMessage(conversationId, "为了保护你的凭证,`/login` 只能在与机器人的私聊中使用。请先私信机器人,再重新执行 `/login`。");
382
+ return;
383
+ }
384
+ const baseUrl = normalizeLoginBaseUrl();
385
+ if (!baseUrl) {
386
+ await bot.postMessage(conversationId, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
387
+ return;
388
+ }
389
+ let vaultId;
390
+ try {
391
+ vaultId = ensureLoginVault(platform, platformUserId);
392
+ }
393
+ catch (error) {
394
+ log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
395
+ await bot.postMessage(conversationId, "Login setup failed on the server. 请稍后重试,或联系管理员检查 vault 存储权限。");
396
+ return;
397
+ }
398
+ const loginLabel = "credential";
399
+ const vaultLabel = sandbox.type === "container" ? "the shared container vault" : "your vault";
400
+ await bot.postMessage(conversationId, `Open this link to store ${loginLabel} in ${vaultLabel} ` +
401
+ `(expires in 15 minutes):\n${baseUrl}/link?token=${linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "").token}`);
230
402
  },
231
403
  async handleEvent(event, bot, adapters, _isEvent) {
232
404
  // Don't accept new events during shutdown
233
405
  if (isShuttingDown) {
234
- log.logInfo(`[${event.channel}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
406
+ log.logInfo(`[${event.conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
235
407
  return;
236
408
  }
237
- const rawSessionKey = `${event.channel}:${event.thread_ts ?? event.ts}`;
238
- const sessionKey = this.resolveSessionKey(rawSessionKey);
239
- const state = await getState(event.channel, sessionKey);
409
+ const sessionKey = event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`;
410
+ const state = await getState(event.conversationId, sessionKey);
240
411
  // Start run
241
412
  state.running = true;
242
413
  state.stopRequested = false;
243
414
  state.startedAt = Date.now();
244
415
  state.lastActivityAt = Date.now();
245
- log.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);
416
+ log.logInfo(`[${event.conversationId}] Starting run: ${event.text.substring(0, 50)}`);
246
417
  // Wrap in-flight run tracking
247
- const runPromise = (async () => {
248
- try {
418
+ Sentry.metrics.count("agent.run.started", 1, {
419
+ attributes: { channel: event.conversationId },
420
+ });
421
+ Sentry.metrics.gauge("agent.sessions.active", inFlightRuns.size + 1);
422
+ const runPromise = Sentry.startSpan({
423
+ name: "agent.run",
424
+ op: "agent",
425
+ attributes: { channelId: event.conversationId, sessionKey },
426
+ }, async () => {
427
+ return Sentry.withScope(async (scope) => {
249
428
  const { message, responseCtx, platform } = adapters;
250
- // Run the agent
251
- await responseCtx.setTyping(true);
252
- await responseCtx.setWorking(true);
253
- const result = await state.runner.run(message, responseCtx, platform);
254
- await responseCtx.setWorking(false);
255
- if (result.stopReason === "aborted" && state.stopRequested) {
256
- if (state.stopMessageTs) {
257
- await bot.updateMessage(event.channel, state.stopMessageTs, "_Stopped_");
258
- state.stopMessageTs = undefined;
259
- }
260
- else {
261
- await bot.postMessage(event.channel, "_Stopped_");
429
+ applyRunScope(scope, {
430
+ conversationId: event.conversationId,
431
+ sessionKey,
432
+ messageId: message.id,
433
+ platform: platform.name,
434
+ userId: message.userId,
435
+ userName: message.userName,
436
+ threadTs: message.threadTs,
437
+ isEvent: _isEvent,
438
+ });
439
+ addLifecycleBreadcrumb("agent.run.started", {
440
+ channel_id: event.conversationId,
441
+ platform: platform.name,
442
+ has_attachments: (message.attachments?.length ?? 0) > 0,
443
+ });
444
+ try {
445
+ await responseCtx.setTyping(true);
446
+ await responseCtx.setWorking(true);
447
+ const result = await state.runner.run(message, responseCtx, platform);
448
+ await responseCtx.setWorking(false);
449
+ const durationMs = Date.now() - state.startedAt;
450
+ Sentry.metrics.distribution("agent.run.duration", durationMs, {
451
+ unit: "millisecond",
452
+ attributes: {
453
+ channel: event.conversationId,
454
+ platform: platform.name,
455
+ stop_reason: result.stopReason,
456
+ },
457
+ });
458
+ Sentry.metrics.count("agent.run.completed", 1, {
459
+ attributes: {
460
+ channel: event.conversationId,
461
+ platform: platform.name,
462
+ stop_reason: result.stopReason,
463
+ },
464
+ });
465
+ addLifecycleBreadcrumb("agent.run.completed", {
466
+ channel_id: event.conversationId,
467
+ platform: platform.name,
468
+ stop_reason: result.stopReason,
469
+ duration_ms: durationMs,
470
+ });
471
+ if (result.stopReason === "aborted" && state.stopRequested) {
472
+ if (state.stopMessageTs) {
473
+ await bot.updateMessage(event.conversationId, state.stopMessageTs, "_Stopped_");
474
+ state.stopMessageTs = undefined;
475
+ }
476
+ else {
477
+ await bot.postMessage(event.conversationId, "_Stopped_");
478
+ }
262
479
  }
263
480
  }
264
- }
265
- catch (err) {
266
- log.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));
267
- }
268
- finally {
269
- state.running = false;
270
- state.lastAccessedAt = Date.now();
271
- evictIdleSessions();
272
- }
273
- })();
481
+ catch (err) {
482
+ scope.setContext("agent_run_error", {
483
+ conversationId: event.conversationId,
484
+ sessionKey,
485
+ platform: adapters.platform.name,
486
+ messageId: adapters.message.id,
487
+ threadTs: adapters.message.threadTs,
488
+ });
489
+ Sentry.captureException(err);
490
+ Sentry.metrics.count("agent.run.errors", 1, {
491
+ attributes: { channel: event.conversationId, platform: adapters.platform.name },
492
+ });
493
+ log.logWarning(`[${event.conversationId}] Run error`, err instanceof Error ? err.message : String(err));
494
+ }
495
+ finally {
496
+ state.running = false;
497
+ state.lastAccessedAt = Date.now();
498
+ Sentry.metrics.gauge("agent.sessions.active", inFlightRuns.size - 1);
499
+ evictIdleSessions();
500
+ }
501
+ });
502
+ });
274
503
  inFlightRuns.add(runPromise);
275
504
  try {
276
505
  await runPromise;
@@ -285,10 +514,20 @@ const handler = {
285
514
  // ============================================================================
286
515
  const sandboxDesc = sandbox.type === "host"
287
516
  ? "host"
288
- : sandbox.type === "docker"
289
- ? `docker:${sandbox.container}`
290
- : `firecracker:${sandbox.vmId}`;
517
+ : sandbox.type === "container"
518
+ ? `container:${sandbox.container}`
519
+ : sandbox.type === "image"
520
+ ? `image:${sandbox.image}`
521
+ : `firecracker:${sandbox.vmId}`;
291
522
  log.logStartup(workingDir, sandboxDesc);
523
+ // Start link callback server if port is configured
524
+ if (MOM_LINK_PORT) {
525
+ startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, msg) => {
526
+ const bot = botsByPlatform[platform];
527
+ if (bot)
528
+ await bot.postMessage(conversationId, msg);
529
+ });
530
+ }
292
531
  // Create platform bots
293
532
  const bots = [];
294
533
  const botsByPlatform = {};
@@ -343,6 +582,7 @@ async function shutdown() {
343
582
  log.logWarning(`Forcing exit with ${inFlightRuns.size} runs still in progress`);
344
583
  }
345
584
  eventsWatcher.stop();
585
+ await Sentry.close(5000);
346
586
  process.exit(0);
347
587
  }
348
588
  process.on("SIGINT", shutdown);