@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.2

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 (147) hide show
  1. package/README.md +94 -27
  2. package/dist/adapter.d.ts +9 -5
  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.map +1 -1
  6. package/dist/adapters/discord/bot.js +9 -6
  7. package/dist/adapters/discord/bot.js.map +1 -1
  8. package/dist/adapters/discord/context.d.ts.map +1 -1
  9. package/dist/adapters/discord/context.js +16 -13
  10. package/dist/adapters/discord/context.js.map +1 -1
  11. package/dist/adapters/slack/bot.d.ts +10 -2
  12. package/dist/adapters/slack/bot.d.ts.map +1 -1
  13. package/dist/adapters/slack/bot.js +196 -32
  14. package/dist/adapters/slack/bot.js.map +1 -1
  15. package/dist/adapters/slack/context.d.ts.map +1 -1
  16. package/dist/adapters/slack/context.js +24 -17
  17. package/dist/adapters/slack/context.js.map +1 -1
  18. package/dist/adapters/telegram/bot.d.ts +2 -0
  19. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  20. package/dist/adapters/telegram/bot.js +109 -29
  21. package/dist/adapters/telegram/bot.js.map +1 -1
  22. package/dist/adapters/telegram/context.d.ts.map +1 -1
  23. package/dist/adapters/telegram/context.js +8 -43
  24. package/dist/adapters/telegram/context.js.map +1 -1
  25. package/dist/adapters/telegram/html.d.ts +3 -0
  26. package/dist/adapters/telegram/html.d.ts.map +1 -0
  27. package/dist/adapters/telegram/html.js +98 -0
  28. package/dist/adapters/telegram/html.js.map +1 -0
  29. package/dist/agent.d.ts +4 -9
  30. package/dist/agent.d.ts.map +1 -1
  31. package/dist/agent.js +141 -92
  32. package/dist/agent.js.map +1 -1
  33. package/dist/bindings.d.ts +44 -0
  34. package/dist/bindings.d.ts.map +1 -0
  35. package/dist/bindings.js +74 -0
  36. package/dist/bindings.js.map +1 -0
  37. package/dist/config.d.ts +7 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +53 -12
  40. package/dist/config.js.map +1 -1
  41. package/dist/context.d.ts +7 -7
  42. package/dist/context.d.ts.map +1 -1
  43. package/dist/context.js +9 -9
  44. package/dist/context.js.map +1 -1
  45. package/dist/events.d.ts +14 -5
  46. package/dist/events.d.ts.map +1 -1
  47. package/dist/events.js +45 -10
  48. package/dist/events.js.map +1 -1
  49. package/dist/execution-resolver.d.ts +20 -0
  50. package/dist/execution-resolver.d.ts.map +1 -0
  51. package/dist/execution-resolver.js +49 -0
  52. package/dist/execution-resolver.js.map +1 -0
  53. package/dist/instrument.d.ts.map +1 -1
  54. package/dist/instrument.js +2 -1
  55. package/dist/instrument.js.map +1 -1
  56. package/dist/link-server.d.ts +17 -0
  57. package/dist/link-server.d.ts.map +1 -0
  58. package/dist/link-server.js +899 -0
  59. package/dist/link-server.js.map +1 -0
  60. package/dist/link-token.d.ts +32 -0
  61. package/dist/link-token.d.ts.map +1 -0
  62. package/dist/link-token.js +68 -0
  63. package/dist/link-token.js.map +1 -0
  64. package/dist/log.d.ts +2 -2
  65. package/dist/log.d.ts.map +1 -1
  66. package/dist/log.js +7 -7
  67. package/dist/log.js.map +1 -1
  68. package/dist/login.d.ts +29 -0
  69. package/dist/login.d.ts.map +1 -0
  70. package/dist/login.js +164 -0
  71. package/dist/login.js.map +1 -0
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +226 -55
  74. package/dist/main.js.map +1 -1
  75. package/dist/provisioner.d.ts +52 -0
  76. package/dist/provisioner.d.ts.map +1 -0
  77. package/dist/provisioner.js +291 -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 +10 -0
  92. package/dist/sandbox/host.d.ts.map +1 -0
  93. package/dist/sandbox/host.js +85 -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 +1 -1
  116. package/dist/sentry.d.ts.map +1 -1
  117. package/dist/sentry.js +4 -2
  118. package/dist/sentry.js.map +1 -1
  119. package/dist/session-store.d.ts +2 -6
  120. package/dist/session-store.d.ts.map +1 -1
  121. package/dist/session-store.js +3 -10
  122. package/dist/session-store.js.map +1 -1
  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 +22 -0
  128. package/dist/tools/event.d.ts.map +1 -0
  129. package/dist/tools/event.js +104 -0
  130. package/dist/tools/event.js.map +1 -0
  131. package/dist/tools/index.d.ts +7 -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 +12 -0
  136. package/dist/ui-copy.d.ts.map +1 -0
  137. package/dist/ui-copy.js +36 -0
  138. package/dist/ui-copy.js.map +1 -0
  139. package/dist/vault-routing.d.ts +9 -0
  140. package/dist/vault-routing.d.ts.map +1 -0
  141. package/dist/vault-routing.js +52 -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/package.json +12 -11
package/dist/main.js CHANGED
@@ -1,20 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import "./instrument.js";
3
3
  import { join, resolve } from "path";
4
- import { readFileSync } from "fs";
4
+ import { mkdirSync, readFileSync, statSync } from "fs";
5
+ import { homedir } from "os";
5
6
  import { fileURLToPath } from "url";
6
7
  import { dirname, join as pathJoin } from "path";
7
8
  import { DiscordBot } from "./adapters/discord/index.js";
8
9
  import { TelegramBot } from "./adapters/telegram/index.js";
9
10
  import { SlackBot as SlackBotClass } from "./adapters/slack/index.js";
10
11
  import { createRunner } from "./agent.js";
11
- import { createManagedSessionFile, createManagedSessionFileAtPath, getSessionDir, getThreadSessionFile, } from "./session-store.js";
12
+ import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, } from "./session-store.js";
12
13
  import { downloadChannel } from "./download.js";
13
14
  import { createEventsWatcher } from "./events.js";
14
15
  import * as log from "./log.js";
15
- 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 { FileVaultManager } from "./vault.js";
23
+ import { createManagedVaultEntry, ensureSandboxVaultEntry, resolveActorVaultKey, } from "./vault-routing.js";
16
24
  import { addLifecycleBreadcrumb, applyRunScope } from "./sentry.js";
17
25
  import { ChannelStore } from "./store.js";
26
+ import { formatNothingRunning, formatStopped, formatStopping } from "./ui-copy.js";
18
27
  import * as Sentry from "@sentry/node";
19
28
  // ============================================================================
20
29
  // Config
@@ -43,10 +52,17 @@ const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
43
52
  const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
44
53
  const MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;
45
54
  const MOM_DISCORD_BOT_TOKEN = process.env.MOM_DISCORD_BOT_TOKEN;
55
+ const MOM_LINK_URL = process.env.MOM_LINK_URL;
56
+ const MOM_LINK_PORT = process.env.MOM_LINK_PORT
57
+ ? parseInt(process.env.MOM_LINK_PORT, 10)
58
+ : MOM_LINK_URL
59
+ ? 8181
60
+ : undefined;
46
61
  function parseArgs() {
47
62
  const args = process.argv.slice(2);
48
63
  let sandbox = { type: "host" };
49
64
  let workingDir;
65
+ let stateDirArg;
50
66
  let downloadChannelId;
51
67
  let showVersion = false;
52
68
  for (let i = 0; i < args.length; i++) {
@@ -60,6 +76,12 @@ function parseArgs() {
60
76
  else if (arg === "--sandbox") {
61
77
  sandbox = parseSandboxArg(args[++i] || "");
62
78
  }
79
+ else if (arg.startsWith("--state-dir=")) {
80
+ stateDirArg = arg.slice("--state-dir=".length);
81
+ }
82
+ else if (arg === "--state-dir") {
83
+ stateDirArg = args[++i];
84
+ }
63
85
  else if (arg.startsWith("--download=")) {
64
86
  downloadChannelId = arg.slice("--download=".length);
65
87
  }
@@ -72,12 +94,60 @@ function parseArgs() {
72
94
  }
73
95
  return {
74
96
  workingDir: workingDir ? resolve(workingDir) : undefined,
97
+ stateDir: stateDirArg ? resolve(stateDirArg) : undefined,
75
98
  sandbox,
76
99
  downloadChannel: downloadChannelId,
77
100
  showVersion,
78
101
  };
79
102
  }
80
- const parsedArgs = parseArgs();
103
+ const WORLD_WRITABLE_MODE = 0o002;
104
+ function ensureSecureStateDir(path) {
105
+ let stat;
106
+ try {
107
+ stat = statSync(path);
108
+ }
109
+ catch (err) {
110
+ const code = err.code;
111
+ if (code === "ENOENT") {
112
+ mkdirSync(path, { recursive: true, mode: 0o700 });
113
+ return;
114
+ }
115
+ console.error(`Error: cannot access --state-dir ${path}: ${err.message}`);
116
+ process.exit(1);
117
+ }
118
+ if (!stat.isDirectory()) {
119
+ console.error(`Error: --state-dir ${path} exists but is not a directory`);
120
+ process.exit(1);
121
+ }
122
+ if (stat.mode & WORLD_WRITABLE_MODE) {
123
+ console.error(`Error: --state-dir ${path} is world-writable (mode ${(stat.mode & 0o777).toString(8)}). ` +
124
+ `Credentials stored there would be exposed to other local users. ` +
125
+ `Fix with: chmod 0700 ${path}`);
126
+ process.exit(1);
127
+ }
128
+ const euid = typeof process.geteuid === "function" ? process.geteuid() : undefined;
129
+ if (euid !== undefined && stat.uid !== euid) {
130
+ console.error(`Error: --state-dir ${path} is owned by uid ${stat.uid} but mama is running as uid ${euid}. ` +
131
+ `Run mama as the directory owner or point --state-dir at a directory you own.`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+ function handleStartupError(error) {
136
+ if (error instanceof SandboxError) {
137
+ for (const line of error.formatForCli()) {
138
+ console.error(line);
139
+ }
140
+ process.exit(1);
141
+ }
142
+ throw error;
143
+ }
144
+ let parsedArgs;
145
+ try {
146
+ parsedArgs = parseArgs();
147
+ }
148
+ catch (error) {
149
+ handleStartupError(error);
150
+ }
81
151
  // Handle --version
82
152
  if (parsedArgs.showVersion) {
83
153
  console.log(getVersion());
@@ -94,11 +164,14 @@ if (parsedArgs.downloadChannel) {
94
164
  }
95
165
  // Normal bot mode - require working dir
96
166
  if (!parsedArgs.workingDir) {
97
- console.error("Usage: mama [--sandbox=host|docker:<name>|firecracker:<vm-id>:<host-path>] <working-directory>");
167
+ console.error("Usage: mama [--state-dir=<dir>] [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] <working-directory>");
98
168
  console.error(" mama --download <channel-id>");
99
169
  process.exit(1);
100
170
  }
101
171
  const { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };
172
+ const stateDir = parsedArgs.stateDir ?? join(homedir(), ".mama");
173
+ process.env.MAMA_STATE_DIR = stateDir;
174
+ ensureSecureStateDir(stateDir);
102
175
  // Validate platform tokens
103
176
  const hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);
104
177
  const hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;
@@ -110,8 +183,86 @@ if (!hasSlack && !hasTelegram && !hasDiscord) {
110
183
  " Discord: MOM_DISCORD_BOT_TOKEN");
111
184
  process.exit(1);
112
185
  }
113
- await validateSandbox(sandbox);
114
- const channelStates = new Map();
186
+ try {
187
+ await validateSandbox(sandbox);
188
+ }
189
+ catch (error) {
190
+ handleStartupError(error);
191
+ }
192
+ const vaultManager = new FileVaultManager(stateDir);
193
+ if (vaultManager.isEnabled()) {
194
+ console.log(sandbox.type === "container"
195
+ ? " Vault system enabled. Container vault active."
196
+ : sandbox.type === "image" || sandbox.type === "firecracker"
197
+ ? " Vault system enabled. Per-user credential routing active."
198
+ : " Vault system enabled. Host mode will not inject vault env.");
199
+ }
200
+ const bindingStore = new FileUserBindingStore(stateDir);
201
+ if (bindingStore.isEnabled()) {
202
+ console.log(sandbox.type === "container"
203
+ ? " Binding store enabled. Container mode uses the container vault."
204
+ : sandbox.type === "image" || sandbox.type === "firecracker"
205
+ ? " Binding store enabled. Platform user → vault routing active."
206
+ : " Binding store enabled. Host mode will not inject vault env.");
207
+ }
208
+ const provisioner = sandbox.type === "image" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;
209
+ const linkTokenStore = new InMemoryLinkTokenStore();
210
+ setInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();
211
+ function normalizeLoginBaseUrl() {
212
+ if (MOM_LINK_URL) {
213
+ return MOM_LINK_URL.replace(/\/+$/, "");
214
+ }
215
+ if (MOM_LINK_PORT) {
216
+ return `http://localhost:${MOM_LINK_PORT}`;
217
+ }
218
+ return undefined;
219
+ }
220
+ function isPrivateConversation(event) {
221
+ return (event.conversationKind === "direct" ||
222
+ event.type === "dm" ||
223
+ event.sessionKey === event.conversationId);
224
+ }
225
+ function ensureLoginVault(platform, platformUserId) {
226
+ const vaultId = resolveActorVaultKey(sandbox, vaultManager, bindingStore, platform, platformUserId);
227
+ ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);
228
+ if (sandbox.type !== "container" && sandbox.type !== "image") {
229
+ vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId));
230
+ }
231
+ return vaultId;
232
+ }
233
+ async function replyWithContext(responseCtx, text) {
234
+ await responseCtx.setTyping(false);
235
+ await responseCtx.setWorking(false);
236
+ await responseCtx.respond(text);
237
+ }
238
+ async function handleLoginCommand(platform, platformUserId, conversationId, responseCtx, commandText, privateConversation) {
239
+ const parsed = parseLoginCommand(commandText);
240
+ if (!parsed)
241
+ return false;
242
+ if (!privateConversation) {
243
+ await replyWithContext(responseCtx, "為了保護你的憑證,`/login` 只能在與機器人的私訊中使用。請先私訊機器人,再重新執行 `/login`。");
244
+ return true;
245
+ }
246
+ const baseUrl = normalizeLoginBaseUrl();
247
+ if (!baseUrl) {
248
+ await replyWithContext(responseCtx, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
249
+ return true;
250
+ }
251
+ let vaultId;
252
+ try {
253
+ vaultId = ensureLoginVault(platform, platformUserId);
254
+ }
255
+ catch (error) {
256
+ log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
257
+ await replyWithContext(responseCtx, "Login setup failed on the server. 請稍後重試,或聯絡管理員檢查 vault 儲存權限。");
258
+ return true;
259
+ }
260
+ const token = linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "");
261
+ const vaultLabel = sandbox.type === "container" ? `container vault (${vaultId})` : "your vault";
262
+ await replyWithContext(responseCtx, `Open this link to store credentials in ${vaultLabel} (expires in 15 minutes):\n${baseUrl}/link?token=${token.token}`);
263
+ return true;
264
+ }
265
+ const conversationStates = new Map();
115
266
  /** Track in-flight runs for graceful shutdown */
116
267
  const inFlightRuns = new Set();
117
268
  /** Flag to stop accepting new events during shutdown */
@@ -120,18 +271,25 @@ let isShuttingDown = false;
120
271
  const MAX_SESSIONS = 500;
121
272
  /** Idle timeout before a non-running session can be evicted (1 hour) */
122
273
  const IDLE_TIMEOUT_MS = 3600000;
123
- async function getState(channelId, sessionKey) {
124
- const key = sessionKey ?? channelId;
125
- let state = channelStates.get(key);
274
+ /** Idle timeout for managed image containers (10 minutes) */
275
+ const IMAGE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
276
+ if (provisioner) {
277
+ await provisioner.reconcile();
278
+ await provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS);
279
+ setInterval(() => provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS), IMAGE_IDLE_TIMEOUT_MS).unref();
280
+ }
281
+ async function getState(conversationId, sessionKey) {
282
+ const key = sessionKey ?? conversationId;
283
+ let state = conversationStates.get(key);
126
284
  if (!state) {
127
- const channelDir = join(workingDir, channelId);
285
+ const conversationDir = join(workingDir, conversationId);
128
286
  state = {
129
287
  running: false,
130
- runner: await createRunner(sandbox, key, channelId, channelDir, workingDir),
288
+ runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner),
131
289
  stopRequested: false,
132
290
  lastAccessedAt: Date.now(),
133
291
  };
134
- channelStates.set(key, state);
292
+ conversationStates.set(key, state);
135
293
  }
136
294
  else {
137
295
  state.lastAccessedAt = Date.now();
@@ -139,27 +297,27 @@ async function getState(channelId, sessionKey) {
139
297
  return state;
140
298
  }
141
299
  /**
142
- * Evict idle sessions from channelStates to bound memory usage.
300
+ * Evict idle sessions from conversationStates to bound memory usage.
143
301
  * Called after each handleEvent completes.
144
302
  */
145
303
  function evictIdleSessions() {
146
304
  const now = Date.now();
147
- for (const [key, state] of channelStates) {
305
+ for (const [key, state] of conversationStates) {
148
306
  if (!state.running && now - state.lastAccessedAt > IDLE_TIMEOUT_MS) {
149
- channelStates.delete(key);
307
+ conversationStates.delete(key);
150
308
  }
151
309
  }
152
- if (channelStates.size > MAX_SESSIONS) {
310
+ if (conversationStates.size > MAX_SESSIONS) {
153
311
  const idleSessions = [];
154
- for (const [key, state] of channelStates) {
312
+ for (const [key, state] of conversationStates) {
155
313
  if (!state.running) {
156
314
  idleSessions.push({ key, lastAccessedAt: state.lastAccessedAt });
157
315
  }
158
316
  }
159
317
  idleSessions.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
160
- const toEvict = channelStates.size - MAX_SESSIONS;
318
+ const toEvict = conversationStates.size - MAX_SESSIONS;
161
319
  for (let i = 0; i < toEvict && i < idleSessions.length; i++) {
162
- channelStates.delete(idleSessions[i].key);
320
+ conversationStates.delete(idleSessions[i].key);
163
321
  }
164
322
  }
165
323
  }
@@ -168,12 +326,12 @@ function evictIdleSessions() {
168
326
  // ============================================================================
169
327
  const handler = {
170
328
  isRunning(sessionKey) {
171
- const state = channelStates.get(sessionKey);
329
+ const state = conversationStates.get(sessionKey);
172
330
  return !!state?.running;
173
331
  },
174
332
  getRunningSessions() {
175
333
  const sessions = [];
176
- for (const [sessionKey, state] of channelStates) {
334
+ for (const [sessionKey, state] of conversationStates) {
177
335
  if (state.running && state.startedAt) {
178
336
  // Get current step from runner
179
337
  const currentStep = state.runner.getCurrentStep();
@@ -187,20 +345,20 @@ const handler = {
187
345
  }
188
346
  return sessions;
189
347
  },
190
- async handleStop(sessionKey, channelId, bot) {
191
- const state = channelStates.get(sessionKey);
348
+ async handleStop(sessionKey, conversationId, bot) {
349
+ const state = conversationStates.get(sessionKey);
192
350
  if (state?.running) {
193
351
  state.stopRequested = true;
194
352
  state.runner.abort();
195
- const ts = await bot.postMessage(channelId, "_Stopping..._");
353
+ const ts = await bot.postMessage(conversationId, formatStopping(bot));
196
354
  state.stopMessageTs = ts;
197
355
  }
198
356
  else {
199
- await bot.postMessage(channelId, "_Nothing running_");
357
+ await bot.postMessage(conversationId, formatNothingRunning(bot));
200
358
  }
201
359
  },
202
360
  forceStop(sessionKey) {
203
- const state = channelStates.get(sessionKey);
361
+ const state = conversationStates.get(sessionKey);
204
362
  if (state?.running) {
205
363
  log.logInfo(`[Force Stop] Force stopping session: ${sessionKey}`);
206
364
  state.stopRequested = true;
@@ -208,49 +366,53 @@ const handler = {
208
366
  state.running = false;
209
367
  }
210
368
  },
211
- async handleNew(sessionKey, channelId, bot) {
212
- const state = channelStates.get(sessionKey);
369
+ async handleNew(sessionKey, conversationId, bot) {
370
+ const state = conversationStates.get(sessionKey);
213
371
  if (state?.running) {
214
372
  state.stopRequested = true;
215
373
  state.runner.abort();
216
374
  }
217
- // Channel sessions rotate via current pointer. Thread sessions reset in place.
218
- const channelDir = join(workingDir, channelId);
375
+ // Conversation sessions rotate via current pointer. Thread sessions reset in place.
376
+ const conversationDir = join(workingDir, conversationId);
219
377
  if (sessionKey.includes(":")) {
220
- createManagedSessionFileAtPath(getThreadSessionFile(channelDir, sessionKey), channelDir);
378
+ createManagedSessionFileAtPath(getThreadSessionFile(conversationDir, sessionKey), conversationDir);
221
379
  }
222
380
  else {
223
- createManagedSessionFile(getSessionDir(channelDir, sessionKey), channelDir);
381
+ createManagedSessionFile(getChannelSessionDir(conversationDir), conversationDir);
224
382
  }
225
383
  // Remove from in-memory cache
226
- channelStates.delete(sessionKey);
227
- log.logInfo(`[${channelId}] Session reset: ${sessionKey}`);
228
- await bot.postMessage(channelId, "Conversation reset. Send a new message to start fresh.");
384
+ conversationStates.delete(sessionKey);
385
+ log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);
386
+ await bot.postMessage(conversationId, "Conversation reset. Send a new message to start fresh.");
229
387
  },
230
388
  async handleEvent(event, bot, adapters, _isEvent) {
389
+ const conversationId = event.conversationId;
231
390
  // Don't accept new events during shutdown
232
391
  if (isShuttingDown) {
233
- log.logInfo(`[${event.channel}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
392
+ log.logInfo(`[${conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
234
393
  return;
235
394
  }
236
- const sessionKey = event.sessionKey ?? `${event.channel}:${event.thread_ts ?? event.ts}`;
237
- const state = await getState(event.channel, sessionKey);
395
+ const sessionKey = event.sessionKey ?? `${conversationId}:${event.thread_ts ?? event.ts}`;
396
+ const handledLogin = await handleLoginCommand(adapters.platform.name, event.user, conversationId, adapters.responseCtx, event.text, isPrivateConversation(event));
397
+ if (handledLogin)
398
+ return;
399
+ const state = await getState(conversationId, sessionKey);
238
400
  // Start run
239
401
  state.running = true;
240
402
  state.stopRequested = false;
241
403
  state.startedAt = Date.now();
242
404
  state.lastActivityAt = Date.now();
243
- log.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);
405
+ log.logInfo(`[${conversationId}] Starting run: ${event.text.substring(0, 50)}`);
244
406
  // Wrap in-flight run tracking
245
407
  Sentry.metrics.count("agent.run.started", 1, {
246
- attributes: { channel: event.channel },
408
+ attributes: { channel: conversationId },
247
409
  });
248
410
  Sentry.metrics.gauge("agent.sessions.active", inFlightRuns.size + 1);
249
- const runPromise = Sentry.startSpan({ name: "agent.run", op: "agent", attributes: { channelId: event.channel, sessionKey } }, async () => {
411
+ const runPromise = Sentry.startSpan({ name: "agent.run", op: "agent", attributes: { conversationId, sessionKey } }, async () => {
250
412
  return Sentry.withScope(async (scope) => {
251
413
  const { message, responseCtx, platform } = adapters;
252
414
  applyRunScope(scope, {
253
- channelId: event.channel,
415
+ conversationId,
254
416
  sessionKey,
255
417
  messageId: message.id,
256
418
  platform: platform.name,
@@ -260,7 +422,7 @@ const handler = {
260
422
  isEvent: _isEvent,
261
423
  });
262
424
  addLifecycleBreadcrumb("agent.run.started", {
263
- channel_id: event.channel,
425
+ channel_id: conversationId,
264
426
  platform: platform.name,
265
427
  has_attachments: (message.attachments?.length ?? 0) > 0,
266
428
  });
@@ -273,37 +435,37 @@ const handler = {
273
435
  Sentry.metrics.distribution("agent.run.duration", durationMs, {
274
436
  unit: "millisecond",
275
437
  attributes: {
276
- channel: event.channel,
438
+ channel: conversationId,
277
439
  platform: platform.name,
278
440
  stop_reason: result.stopReason,
279
441
  },
280
442
  });
281
443
  Sentry.metrics.count("agent.run.completed", 1, {
282
444
  attributes: {
283
- channel: event.channel,
445
+ channel: conversationId,
284
446
  platform: platform.name,
285
447
  stop_reason: result.stopReason,
286
448
  },
287
449
  });
288
450
  addLifecycleBreadcrumb("agent.run.completed", {
289
- channel_id: event.channel,
451
+ channel_id: conversationId,
290
452
  platform: platform.name,
291
453
  stop_reason: result.stopReason,
292
454
  duration_ms: durationMs,
293
455
  });
294
456
  if (result.stopReason === "aborted" && state.stopRequested) {
295
457
  if (state.stopMessageTs) {
296
- await bot.updateMessage(event.channel, state.stopMessageTs, "_Stopped_");
458
+ await bot.updateMessage(conversationId, state.stopMessageTs, formatStopped(bot));
297
459
  state.stopMessageTs = undefined;
298
460
  }
299
461
  else {
300
- await bot.postMessage(event.channel, "_Stopped_");
462
+ await bot.postMessage(conversationId, formatStopped(bot));
301
463
  }
302
464
  }
303
465
  }
304
466
  catch (err) {
305
467
  scope.setContext("agent_run_error", {
306
- channelId: event.channel,
468
+ conversationId,
307
469
  sessionKey,
308
470
  platform: adapters.platform.name,
309
471
  messageId: adapters.message.id,
@@ -311,9 +473,9 @@ const handler = {
311
473
  });
312
474
  Sentry.captureException(err);
313
475
  Sentry.metrics.count("agent.run.errors", 1, {
314
- attributes: { channel: event.channel, platform: adapters.platform.name },
476
+ attributes: { channel: conversationId, platform: adapters.platform.name },
315
477
  });
316
- log.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));
478
+ log.logWarning(`[${conversationId}] Run error`, err instanceof Error ? err.message : String(err));
317
479
  }
318
480
  finally {
319
481
  state.running = false;
@@ -337,9 +499,11 @@ const handler = {
337
499
  // ============================================================================
338
500
  const sandboxDesc = sandbox.type === "host"
339
501
  ? "host"
340
- : sandbox.type === "docker"
341
- ? `docker:${sandbox.container}`
342
- : `firecracker:${sandbox.vmId}`;
502
+ : sandbox.type === "container"
503
+ ? `container:${sandbox.container}`
504
+ : sandbox.type === "image"
505
+ ? `image:${sandbox.image}`
506
+ : `firecracker:${sandbox.vmId}`;
343
507
  log.logStartup(workingDir, sandboxDesc);
344
508
  // Create platform bots
345
509
  const bots = [];
@@ -374,6 +538,13 @@ if (hasDiscord) {
374
538
  botsByPlatform.discord = discordBot;
375
539
  log.logInfo("Platform: Discord");
376
540
  }
541
+ if (MOM_LINK_PORT) {
542
+ startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, message) => {
543
+ const bot = botsByPlatform[platform];
544
+ if (bot)
545
+ await bot.postMessage(conversationId, message);
546
+ });
547
+ }
377
548
  // Start events watcher with explicit platform routing
378
549
  const eventsWatcher = createEventsWatcher(workingDir, botsByPlatform);
379
550
  const slackBot = botsByPlatform.slack;