@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.3

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 (149) hide show
  1. package/README.md +133 -78
  2. package/dist/adapter.d.ts +22 -10
  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 +10 -7
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +228 -69
  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 +92 -34
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/shared.d.ts +23 -0
  13. package/dist/adapters/shared.d.ts.map +1 -0
  14. package/dist/adapters/shared.js +57 -0
  15. package/dist/adapters/shared.js.map +1 -0
  16. package/dist/adapters/slack/bot.d.ts +19 -11
  17. package/dist/adapters/slack/bot.d.ts.map +1 -1
  18. package/dist/adapters/slack/bot.js +356 -96
  19. package/dist/adapters/slack/bot.js.map +1 -1
  20. package/dist/adapters/slack/branch-manager.d.ts +21 -0
  21. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  22. package/dist/adapters/slack/branch-manager.js +96 -0
  23. package/dist/adapters/slack/branch-manager.js.map +1 -0
  24. package/dist/adapters/slack/context.d.ts.map +1 -1
  25. package/dist/adapters/slack/context.js +100 -67
  26. package/dist/adapters/slack/context.js.map +1 -1
  27. package/dist/adapters/slack/session.d.ts +3 -0
  28. package/dist/adapters/slack/session.d.ts.map +1 -0
  29. package/dist/adapters/slack/session.js +16 -0
  30. package/dist/adapters/slack/session.js.map +1 -0
  31. package/dist/adapters/telegram/bot.d.ts +4 -2
  32. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  33. package/dist/adapters/telegram/bot.js +141 -74
  34. package/dist/adapters/telegram/bot.js.map +1 -1
  35. package/dist/adapters/telegram/context.d.ts.map +1 -1
  36. package/dist/adapters/telegram/context.js +49 -109
  37. package/dist/adapters/telegram/context.js.map +1 -1
  38. package/dist/adapters/telegram/html.d.ts +3 -0
  39. package/dist/adapters/telegram/html.d.ts.map +1 -0
  40. package/dist/adapters/telegram/html.js +98 -0
  41. package/dist/adapters/telegram/html.js.map +1 -0
  42. package/dist/agent.d.ts +4 -11
  43. package/dist/agent.d.ts.map +1 -1
  44. package/dist/agent.js +116 -196
  45. package/dist/agent.js.map +1 -1
  46. package/dist/bindings.d.ts +1 -20
  47. package/dist/bindings.d.ts.map +1 -1
  48. package/dist/bindings.js +1 -21
  49. package/dist/bindings.js.map +1 -1
  50. package/dist/config.d.ts +9 -27
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/config.js +89 -63
  53. package/dist/config.js.map +1 -1
  54. package/dist/context.d.ts +13 -3
  55. package/dist/context.d.ts.map +1 -1
  56. package/dist/context.js +102 -18
  57. package/dist/context.js.map +1 -1
  58. package/dist/events.d.ts +18 -6
  59. package/dist/events.d.ts.map +1 -1
  60. package/dist/events.js +86 -35
  61. package/dist/events.js.map +1 -1
  62. package/dist/execution-resolver.d.ts.map +1 -1
  63. package/dist/execution-resolver.js +1 -3
  64. package/dist/execution-resolver.js.map +1 -1
  65. package/dist/instrument.d.ts.map +1 -1
  66. package/dist/instrument.js +5 -11
  67. package/dist/instrument.js.map +1 -1
  68. package/dist/{login.d.ts → login/index.d.ts} +2 -2
  69. package/dist/login/index.d.ts.map +1 -0
  70. package/dist/{login.js → login/index.js} +2 -2
  71. package/dist/login/index.js.map +1 -0
  72. package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
  73. package/dist/login/portal.d.ts.map +1 -0
  74. package/dist/login/portal.js +1453 -0
  75. package/dist/login/portal.js.map +1 -0
  76. package/dist/{link-token.d.ts → login/session.d.ts} +1 -1
  77. package/dist/login/session.d.ts.map +1 -0
  78. package/dist/{link-token.js → login/session.js} +1 -1
  79. package/dist/login/session.js.map +1 -0
  80. package/dist/main.d.ts.map +1 -1
  81. package/dist/main.js +175 -119
  82. package/dist/main.js.map +1 -1
  83. package/dist/provisioner.d.ts +17 -43
  84. package/dist/provisioner.d.ts.map +1 -1
  85. package/dist/provisioner.js +84 -50
  86. package/dist/provisioner.js.map +1 -1
  87. package/dist/sandbox/host.d.ts +0 -2
  88. package/dist/sandbox/host.d.ts.map +1 -1
  89. package/dist/sandbox/host.js +1 -5
  90. package/dist/sandbox/host.js.map +1 -1
  91. package/dist/sentry.d.ts.map +1 -1
  92. package/dist/sentry.js +2 -0
  93. package/dist/sentry.js.map +1 -1
  94. package/dist/session-policy.d.ts +13 -0
  95. package/dist/session-policy.d.ts.map +1 -0
  96. package/dist/session-policy.js +23 -0
  97. package/dist/session-policy.js.map +1 -0
  98. package/dist/session-store.d.ts +27 -1
  99. package/dist/session-store.d.ts.map +1 -1
  100. package/dist/session-store.js +162 -9
  101. package/dist/session-store.js.map +1 -1
  102. package/dist/session-view/command.d.ts +5 -0
  103. package/dist/session-view/command.d.ts.map +1 -0
  104. package/dist/session-view/command.js +11 -0
  105. package/dist/session-view/command.js.map +1 -0
  106. package/dist/session-view/portal.d.ts +9 -0
  107. package/dist/session-view/portal.d.ts.map +1 -0
  108. package/dist/session-view/portal.js +766 -0
  109. package/dist/session-view/portal.js.map +1 -0
  110. package/dist/session-view/service.d.ts +34 -0
  111. package/dist/session-view/service.d.ts.map +1 -0
  112. package/dist/session-view/service.js +380 -0
  113. package/dist/session-view/service.js.map +1 -0
  114. package/dist/session-view/store.d.ts +16 -0
  115. package/dist/session-view/store.d.ts.map +1 -0
  116. package/dist/session-view/store.js +38 -0
  117. package/dist/session-view/store.js.map +1 -0
  118. package/dist/store.d.ts +3 -6
  119. package/dist/store.d.ts.map +1 -1
  120. package/dist/store.js +15 -35
  121. package/dist/store.js.map +1 -1
  122. package/dist/tools/event.d.ts +3 -0
  123. package/dist/tools/event.d.ts.map +1 -1
  124. package/dist/tools/event.js +27 -8
  125. package/dist/tools/event.js.map +1 -1
  126. package/dist/tools/index.d.ts +3 -0
  127. package/dist/tools/index.d.ts.map +1 -1
  128. package/dist/tools/index.js +2 -2
  129. package/dist/tools/index.js.map +1 -1
  130. package/dist/ui-copy.d.ts +1 -0
  131. package/dist/ui-copy.d.ts.map +1 -1
  132. package/dist/ui-copy.js +3 -0
  133. package/dist/ui-copy.js.map +1 -1
  134. package/dist/vault-routing.d.ts +1 -2
  135. package/dist/vault-routing.d.ts.map +1 -1
  136. package/dist/vault-routing.js +1 -7
  137. package/dist/vault-routing.js.map +1 -1
  138. package/package.json +1 -1
  139. package/dist/link-server.d.ts.map +0 -1
  140. package/dist/link-server.js +0 -839
  141. package/dist/link-server.js.map +0 -1
  142. package/dist/link-token.d.ts.map +0 -1
  143. package/dist/link-token.js.map +0 -1
  144. package/dist/login.d.ts.map +0 -1
  145. package/dist/login.js.map +0 -1
  146. package/dist/vault.test.d.ts +0 -2
  147. package/dist/vault.test.d.ts.map +0 -1
  148. package/dist/vault.test.js +0 -67
  149. package/dist/vault.test.js.map +0 -1
package/dist/main.js CHANGED
@@ -1,30 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import "./instrument.js";
3
3
  import { join, resolve } from "path";
4
- import { homedir } from "os";
5
4
  import { mkdirSync, readFileSync, statSync } from "fs";
5
+ import { homedir } from "os";
6
6
  import { fileURLToPath } from "url";
7
7
  import { dirname, join as pathJoin } from "path";
8
8
  import { DiscordBot } from "./adapters/discord/index.js";
9
9
  import { TelegramBot } from "./adapters/telegram/index.js";
10
10
  import { SlackBot as SlackBotClass } from "./adapters/slack/index.js";
11
11
  import { createRunner } from "./agent.js";
12
- import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, } from "./session-store.js";
12
+ import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, resolveGenericSessionScope, } from "./session-store.js";
13
13
  import { downloadChannel } from "./download.js";
14
14
  import { createEventsWatcher } from "./events.js";
15
15
  import * as log from "./log.js";
16
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";
17
+ import { parseLoginCommand } from "./login/index.js";
18
+ import { startLinkServer } from "./login/portal.js";
19
+ import { InMemoryLinkTokenStore } from "./login/session.js";
20
+ import { parseSessionViewCommand } from "./session-view/command.js";
21
+ import { resolveExistingSessionFile } from "./session-view/service.js";
22
+ import { InMemorySessionViewTokenStore } from "./session-view/store.js";
20
23
  import { DockerContainerManager } from "./provisioner.js";
24
+ import { loadAgentConfig } from "./config.js";
21
25
  import { SandboxError, parseSandboxArg, validateSandbox } from "./sandbox.js";
22
- import { formatNothingRunning, formatStopping } from "./ui-copy.js";
23
26
  import { FileVaultManager } from "./vault.js";
24
- import { ensureSettingsFile } from "./config.js";
25
27
  import { createManagedVaultEntry, ensureSandboxVaultEntry, resolveActorVaultKey, } from "./vault-routing.js";
26
28
  import { addLifecycleBreadcrumb, applyRunScope } from "./sentry.js";
27
29
  import { ChannelStore } from "./store.js";
30
+ import { formatNothingRunning, formatStopped, formatStopping } from "./ui-copy.js";
31
+ import { hasMaterializedSlackBranchSession, resolveSlackSessionScope, waitForSlackBranchBootstrap, } from "./adapters/slack/branch-manager.js";
28
32
  import * as Sentry from "@sentry/node";
29
33
  // ============================================================================
30
34
  // Config
@@ -53,13 +57,7 @@ const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
53
57
  const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
54
58
  const MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;
55
59
  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
60
  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
61
  const MOM_LINK_PORT = process.env.MOM_LINK_PORT
64
62
  ? parseInt(process.env.MOM_LINK_PORT, 10)
65
63
  : MOM_LINK_URL
@@ -108,12 +106,6 @@ function parseArgs() {
108
106
  };
109
107
  }
110
108
  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
109
  function ensureSecureStateDir(path) {
118
110
  let stat;
119
111
  try {
@@ -177,27 +169,14 @@ if (parsedArgs.downloadChannel) {
177
169
  }
178
170
  // Normal bot mode - require working dir
179
171
  if (!parsedArgs.workingDir) {
180
- console.error("Usage: mama [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] [--state-dir=<path>] <working-directory>");
172
+ console.error("Usage: mama [--state-dir=<dir>] [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] <working-directory>");
181
173
  console.error(" mama --download <channel-id>");
182
174
  process.exit(1);
183
175
  }
184
176
  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
177
  const stateDir = parsedArgs.stateDir ?? join(homedir(), ".mama");
188
- ensureSecureStateDir(stateDir);
189
- // Share stateDir with instrument.ts (for Sentry config loading)
190
178
  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
- }
179
+ ensureSecureStateDir(stateDir);
201
180
  // Validate platform tokens
202
181
  const hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);
203
182
  const hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;
@@ -218,37 +197,31 @@ catch (error) {
218
197
  const vaultManager = new FileVaultManager(stateDir);
219
198
  if (vaultManager.isEnabled()) {
220
199
  console.log(sandbox.type === "container"
221
- ? " Vault system enabled. Shared container vault active."
222
- : " Vault system enabled. Per-user credential routing active.");
200
+ ? " Vault system enabled. Container vault active."
201
+ : sandbox.type === "image" || sandbox.type === "firecracker"
202
+ ? " Vault system enabled. Per-user credential routing active."
203
+ : " Vault system enabled. Host mode will not inject vault env.");
223
204
  }
224
205
  const bindingStore = new FileUserBindingStore(stateDir);
225
206
  if (bindingStore.isEnabled()) {
226
207
  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.");
208
+ ? " Binding store enabled. Container mode uses the container vault."
209
+ : sandbox.type === "image" || sandbox.type === "firecracker"
210
+ ? " Binding store enabled. Platform user → vault routing active."
211
+ : " Binding store enabled. Host mode will not inject vault env.");
229
212
  }
230
- const provisioner = sandbox.type === "image" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;
213
+ const startupConfig = loadAgentConfig(workingDir);
214
+ const sandboxLimits = startupConfig.sandboxCpus || startupConfig.sandboxMemory
215
+ ? { cpus: startupConfig.sandboxCpus, memory: startupConfig.sandboxMemory }
216
+ : undefined;
217
+ const provisioner = sandbox.type === "image"
218
+ ? new DockerContainerManager(sandbox.image, workingDir, { limits: sandboxLimits })
219
+ : undefined;
231
220
  const linkTokenStore = new InMemoryLinkTokenStore();
232
- // Purge expired link tokens every 5 minutes
221
+ const sessionViewTokenStore = new InMemorySessionViewTokenStore();
233
222
  setInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();
234
- const conversationStates = new Map();
235
- /** Track in-flight runs for graceful shutdown */
236
- const inFlightRuns = new Set();
237
- /** Flag to stop accepting new events during shutdown */
238
- let isShuttingDown = false;
239
- /** Maximum number of cached sessions */
240
- const MAX_SESSIONS = 500;
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() {
223
+ setInterval(() => sessionViewTokenStore.purge(), 5 * 60 * 1000).unref();
224
+ function normalizePortalBaseUrl() {
252
225
  if (MOM_LINK_URL) {
253
226
  return MOM_LINK_URL.replace(/\/+$/, "");
254
227
  }
@@ -257,22 +230,118 @@ function normalizeLoginBaseUrl() {
257
230
  }
258
231
  return undefined;
259
232
  }
233
+ function isPrivateConversation(event) {
234
+ return event.conversationKind === "direct" || event.type === "dm";
235
+ }
260
236
  function ensureLoginVault(platform, platformUserId) {
261
237
  const vaultId = resolveActorVaultKey(sandbox, vaultManager, bindingStore, platform, platformUserId);
262
238
  ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);
263
- if (sandbox.type !== "image" && sandbox.type !== "container") {
264
- vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId, false));
239
+ if (sandbox.type !== "container" && sandbox.type !== "image") {
240
+ vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId));
265
241
  }
266
242
  return vaultId;
267
243
  }
268
- async function getState(conversationId, sessionKey) {
244
+ async function replyWithContext(responseCtx, text) {
245
+ await responseCtx.setTyping(false);
246
+ await responseCtx.setWorking(false);
247
+ await responseCtx.respond(text);
248
+ }
249
+ async function handleLoginCommand(platform, platformUserId, conversationId, responseCtx, commandText, privateConversation) {
250
+ const parsed = parseLoginCommand(commandText);
251
+ if (!parsed)
252
+ return false;
253
+ if (!privateConversation) {
254
+ await replyWithContext(responseCtx, "為了保護你的憑證,`/login` 只能在與機器人的私訊中使用。請先私訊機器人,再重新執行 `/login`。");
255
+ return true;
256
+ }
257
+ const baseUrl = normalizePortalBaseUrl();
258
+ if (!baseUrl) {
259
+ await replyWithContext(responseCtx, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
260
+ return true;
261
+ }
262
+ let vaultId;
263
+ try {
264
+ vaultId = ensureLoginVault(platform, platformUserId);
265
+ }
266
+ catch (error) {
267
+ log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
268
+ await replyWithContext(responseCtx, "Login setup failed on the server. 請稍後重試,或聯絡管理員檢查 vault 儲存權限。");
269
+ return true;
270
+ }
271
+ const token = linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "");
272
+ const vaultLabel = sandbox.type === "container" ? `container vault (${vaultId})` : "your vault";
273
+ await replyWithContext(responseCtx, `Open this link to store credentials in ${vaultLabel} (expires in 15 minutes):\n${baseUrl}/link?token=${token.token}`);
274
+ return true;
275
+ }
276
+ async function handleSessionViewCommand(platform, platformUserId, conversationId, sessionKey, bot, responseCtx, commandText, privateConversation) {
277
+ if (!parseSessionViewCommand(commandText))
278
+ return false;
279
+ const allowSharedPrivateDelivery = platform === "slack" || platform === "discord";
280
+ const sendSessionViewReply = async (text) => {
281
+ if (privateConversation) {
282
+ await replyWithContext(responseCtx, text);
283
+ return;
284
+ }
285
+ if (platform === "slack" && bot instanceof SlackBotClass) {
286
+ await bot.postEphemeral(conversationId, platformUserId, text);
287
+ return;
288
+ }
289
+ if (platform === "discord" && bot instanceof DiscordBot) {
290
+ await bot.sendDirectMessage(platformUserId, text);
291
+ return;
292
+ }
293
+ await replyWithContext(responseCtx, text);
294
+ };
295
+ if (!privateConversation && !allowSharedPrivateDelivery) {
296
+ await sendSessionViewReply("為了保護對話內容,`/session` 目前只能在與機器人的私訊 / DM 中使用。");
297
+ return true;
298
+ }
299
+ const baseUrl = normalizePortalBaseUrl();
300
+ if (!baseUrl) {
301
+ await sendSessionViewReply("Session viewer is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
302
+ return true;
303
+ }
304
+ const sessionFile = resolveExistingSessionFile(workingDir, conversationId, sessionKey);
305
+ if (!sessionFile) {
306
+ await sendSessionViewReply("目前還沒有可查看的 session。先和機器人對話一次,建立 session 後再試。");
307
+ return true;
308
+ }
309
+ const token = sessionViewTokenStore.create(platform, platformUserId, conversationId, sessionKey, sessionFile);
310
+ const linkText = `Open this read-only session link (expires in 24 hours):\n${baseUrl}/session?token=${token.token}`;
311
+ await sendSessionViewReply(linkText);
312
+ return true;
313
+ }
314
+ const conversationStates = new Map();
315
+ /** Track in-flight runs for graceful shutdown */
316
+ const inFlightRuns = new Set();
317
+ /** Flag to stop accepting new events during shutdown */
318
+ let isShuttingDown = false;
319
+ /** Maximum number of cached sessions */
320
+ const MAX_SESSIONS = 500;
321
+ /** Idle timeout before a non-running session can be evicted (1 hour) */
322
+ const IDLE_TIMEOUT_MS = 3600000;
323
+ /** Idle timeout for managed image containers (10 minutes) */
324
+ const IMAGE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
325
+ if (provisioner) {
326
+ await provisioner.reconcile();
327
+ await provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS);
328
+ setInterval(() => provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS), IMAGE_IDLE_TIMEOUT_MS).unref();
329
+ }
330
+ async function resolveSessionScope(platformName, conversationDir, sessionKey) {
331
+ if (platformName === "slack") {
332
+ return resolveSlackSessionScope({ conversationDir, sessionKey });
333
+ }
334
+ return resolveGenericSessionScope({ conversationDir, sessionKey });
335
+ }
336
+ async function getState(conversationId, platformName, sessionKey) {
269
337
  const key = sessionKey ?? conversationId;
270
338
  let state = conversationStates.get(key);
271
339
  if (!state) {
272
340
  const conversationDir = join(workingDir, conversationId);
341
+ const sessionScope = await resolveSessionScope(platformName, conversationDir, key);
273
342
  state = {
274
343
  running: false,
275
- runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner, stateDir),
344
+ runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, sessionScope, vaultManager, bindingStore, provisioner),
276
345
  stopRequested: false,
277
346
  lastAccessedAt: Date.now(),
278
347
  };
@@ -359,7 +428,7 @@ const handler = {
359
428
  state.stopRequested = true;
360
429
  state.runner.abort();
361
430
  }
362
- // Channel sessions rotate via current pointer. Thread sessions reset in place.
431
+ // Conversation sessions rotate via current pointer. Thread sessions reset in place.
363
432
  const conversationDir = join(workingDir, conversationId);
364
433
  if (sessionKey.includes(":")) {
365
434
  createManagedSessionFileAtPath(getThreadSessionFile(conversationDir, sessionKey), conversationDir);
@@ -372,62 +441,50 @@ const handler = {
372
441
  log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);
373
442
  await bot.postMessage(conversationId, "Conversation reset. Send a new message to start fresh.");
374
443
  },
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}`);
402
- },
403
444
  async handleEvent(event, bot, adapters, _isEvent) {
445
+ const conversationId = event.conversationId;
404
446
  // Don't accept new events during shutdown
405
447
  if (isShuttingDown) {
406
- log.logInfo(`[${event.conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
448
+ log.logInfo(`[${conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
449
+ return;
450
+ }
451
+ const sessionKey = event.sessionKey ?? `${conversationId}:${event.thread_ts ?? event.ts}`;
452
+ const privateConversation = isPrivateConversation(event);
453
+ const handledLogin = await handleLoginCommand(adapters.platform.name, event.user, conversationId, adapters.responseCtx, event.text, privateConversation);
454
+ if (handledLogin)
407
455
  return;
456
+ const handledSessionView = await handleSessionViewCommand(adapters.platform.name, event.user, conversationId, sessionKey, bot, adapters.responseCtx, event.text, privateConversation);
457
+ if (handledSessionView)
458
+ return;
459
+ const conversationDir = join(workingDir, conversationId);
460
+ const waitedForParent = adapters.platform.name === "slack"
461
+ ? await waitForSlackBranchBootstrap({
462
+ parentSessionKey: conversationId,
463
+ sessionKey,
464
+ hasThreadSession: () => hasMaterializedSlackBranchSession(conversationDir, sessionKey),
465
+ isParentRunning: () => conversationStates.get(conversationId)?.running === true,
466
+ })
467
+ : false;
468
+ if (waitedForParent) {
469
+ log.logInfo(`[${conversationId}] Delayed thread bootstrap until parent session sealed: ${sessionKey}`);
408
470
  }
409
- const sessionKey = event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`;
410
- const state = await getState(event.conversationId, sessionKey);
471
+ const state = await getState(conversationId, adapters.platform.name, sessionKey);
411
472
  // Start run
412
473
  state.running = true;
413
474
  state.stopRequested = false;
414
475
  state.startedAt = Date.now();
415
476
  state.lastActivityAt = Date.now();
416
- log.logInfo(`[${event.conversationId}] Starting run: ${event.text.substring(0, 50)}`);
477
+ log.logInfo(`[${conversationId}] Starting run: ${event.text.substring(0, 50)}`);
417
478
  // Wrap in-flight run tracking
418
479
  Sentry.metrics.count("agent.run.started", 1, {
419
- attributes: { channel: event.conversationId },
480
+ attributes: { channel: conversationId },
420
481
  });
421
482
  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 () => {
483
+ const runPromise = Sentry.startSpan({ name: "agent.run", op: "agent", attributes: { conversationId, sessionKey } }, async () => {
427
484
  return Sentry.withScope(async (scope) => {
428
485
  const { message, responseCtx, platform } = adapters;
429
486
  applyRunScope(scope, {
430
- conversationId: event.conversationId,
487
+ conversationId,
431
488
  sessionKey,
432
489
  messageId: message.id,
433
490
  platform: platform.name,
@@ -437,7 +494,7 @@ const handler = {
437
494
  isEvent: _isEvent,
438
495
  });
439
496
  addLifecycleBreadcrumb("agent.run.started", {
440
- channel_id: event.conversationId,
497
+ channel_id: conversationId,
441
498
  platform: platform.name,
442
499
  has_attachments: (message.attachments?.length ?? 0) > 0,
443
500
  });
@@ -450,37 +507,37 @@ const handler = {
450
507
  Sentry.metrics.distribution("agent.run.duration", durationMs, {
451
508
  unit: "millisecond",
452
509
  attributes: {
453
- channel: event.conversationId,
510
+ channel: conversationId,
454
511
  platform: platform.name,
455
512
  stop_reason: result.stopReason,
456
513
  },
457
514
  });
458
515
  Sentry.metrics.count("agent.run.completed", 1, {
459
516
  attributes: {
460
- channel: event.conversationId,
517
+ channel: conversationId,
461
518
  platform: platform.name,
462
519
  stop_reason: result.stopReason,
463
520
  },
464
521
  });
465
522
  addLifecycleBreadcrumb("agent.run.completed", {
466
- channel_id: event.conversationId,
523
+ channel_id: conversationId,
467
524
  platform: platform.name,
468
525
  stop_reason: result.stopReason,
469
526
  duration_ms: durationMs,
470
527
  });
471
528
  if (result.stopReason === "aborted" && state.stopRequested) {
472
529
  if (state.stopMessageTs) {
473
- await bot.updateMessage(event.conversationId, state.stopMessageTs, "_Stopped_");
530
+ await bot.updateMessage(conversationId, state.stopMessageTs, formatStopped(bot));
474
531
  state.stopMessageTs = undefined;
475
532
  }
476
533
  else {
477
- await bot.postMessage(event.conversationId, "_Stopped_");
534
+ await bot.postMessage(conversationId, formatStopped(bot));
478
535
  }
479
536
  }
480
537
  }
481
538
  catch (err) {
482
539
  scope.setContext("agent_run_error", {
483
- conversationId: event.conversationId,
540
+ conversationId,
484
541
  sessionKey,
485
542
  platform: adapters.platform.name,
486
543
  messageId: adapters.message.id,
@@ -488,9 +545,9 @@ const handler = {
488
545
  });
489
546
  Sentry.captureException(err);
490
547
  Sentry.metrics.count("agent.run.errors", 1, {
491
- attributes: { channel: event.conversationId, platform: adapters.platform.name },
548
+ attributes: { channel: conversationId, platform: adapters.platform.name },
492
549
  });
493
- log.logWarning(`[${event.conversationId}] Run error`, err instanceof Error ? err.message : String(err));
550
+ log.logWarning(`[${conversationId}] Run error`, err instanceof Error ? err.message : String(err));
494
551
  }
495
552
  finally {
496
553
  state.running = false;
@@ -520,14 +577,6 @@ const sandboxDesc = sandbox.type === "host"
520
577
  ? `image:${sandbox.image}`
521
578
  : `firecracker:${sandbox.vmId}`;
522
579
  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
- }
531
580
  // Create platform bots
532
581
  const bots = [];
533
582
  const botsByPlatform = {};
@@ -561,6 +610,13 @@ if (hasDiscord) {
561
610
  botsByPlatform.discord = discordBot;
562
611
  log.logInfo("Platform: Discord");
563
612
  }
613
+ if (MOM_LINK_PORT) {
614
+ startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, message) => {
615
+ const bot = botsByPlatform[platform];
616
+ if (bot)
617
+ await bot.postMessage(conversationId, message);
618
+ }, sessionViewTokenStore);
619
+ }
564
620
  // Start events watcher with explicit platform routing
565
621
  const eventsWatcher = createEventsWatcher(workingDir, botsByPlatform);
566
622
  const slackBot = botsByPlatform.slack;