botmux 3.1.0 → 3.1.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 (41) hide show
  1. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  2. package/dist/adapters/backend/sandbox.js +1 -3
  3. package/dist/adapters/backend/sandbox.js.map +1 -1
  4. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/tmux-backend.js +11 -1
  6. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  7. package/dist/core/command-handler.d.ts.map +1 -1
  8. package/dist/core/command-handler.js +143 -86
  9. package/dist/core/command-handler.js.map +1 -1
  10. package/dist/core/session-marker.d.ts.map +1 -1
  11. package/dist/core/session-marker.js +5 -2
  12. package/dist/core/session-marker.js.map +1 -1
  13. package/dist/core/types.d.ts +48 -0
  14. package/dist/core/types.d.ts.map +1 -1
  15. package/dist/core/types.js +55 -0
  16. package/dist/core/types.js.map +1 -1
  17. package/dist/core/worker-pool.d.ts.map +1 -1
  18. package/dist/core/worker-pool.js +10 -8
  19. package/dist/core/worker-pool.js.map +1 -1
  20. package/dist/daemon.d.ts.map +1 -1
  21. package/dist/daemon.js +90 -9
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/i18n/en.d.ts.map +1 -1
  24. package/dist/i18n/en.js +1 -0
  25. package/dist/i18n/en.js.map +1 -1
  26. package/dist/i18n/zh.d.ts.map +1 -1
  27. package/dist/i18n/zh.js +1 -0
  28. package/dist/i18n/zh.js.map +1 -1
  29. package/dist/im/lark/card-handler.d.ts +2 -0
  30. package/dist/im/lark/card-handler.d.ts.map +1 -1
  31. package/dist/im/lark/card-handler.js +219 -139
  32. package/dist/im/lark/card-handler.js.map +1 -1
  33. package/dist/types.d.ts +2 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/utils/child-env.d.ts +8 -0
  36. package/dist/utils/child-env.d.ts.map +1 -1
  37. package/dist/utils/child-env.js +19 -0
  38. package/dist/utils/child-env.js.map +1 -1
  39. package/dist/worker.js +10 -1
  40. package/dist/worker.js.map +1 -1
  41. package/package.json +1 -1
@@ -25,7 +25,7 @@ import { claimPairing } from '../services/pairing-store.js';
25
25
  import { logger } from '../utils/logger.js';
26
26
  import { scheduleTimeZone } from '../utils/timezone.js';
27
27
  import { killWorker, suspendWorker, forkWorker, forkAdoptWorker, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo } from './worker-pool.js';
28
- import { expandHome, getSessionWorkingDir, getProjectScanDirs, rememberLastCliInput } from './session-manager.js';
28
+ import { expandHome, getSessionWorkingDir, getProjectScanDirs, rememberLastCliInput, buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots, } from './session-manager.js';
29
29
  import { discoverSlashCommandsForAdapter, listMcpServerNames, supportsFilesystemCommandDiscovery } from './command-discovery.js';
30
30
  import { validateWorkingDir } from './working-dir.js';
31
31
  import { repinSessionWorkingDir } from './session-cwd.js';
@@ -52,7 +52,7 @@ import { invalidWorkingDirs } from '../utils/working-dir.js';
52
52
  import { writeRoleFile, deleteRoleFile, resolveRole, resolveRoleFile, resolveTeamRoleFile, writeTeamRoleFile, deleteTeamRoleFile, MAX_ROLE_BYTES } from './role-resolver.js';
53
53
  import { getBotCapability, setBotCapability, clearBotCapability } from '../services/bot-profile-store.js';
54
54
  import { deleteRoleProfileEntry, deleteRoleProfileIfEmpty, isValidRoleProfileId, listRoleProfileEntries, listRoleProfiles, MAX_ROLE_PROFILE_ENTRY_BYTES, readRoleProfileEntry, writeRoleProfileEntry, } from '../services/role-profile-store.js';
55
- import { sessionKey, sessionAnchorId } from './types.js';
55
+ import { sessionKey, sessionAnchorId, markRepoCardConsumed, claimCurrentRepoCard } from './types.js';
56
56
  import { t, localeForBot } from '../i18n/index.js';
57
57
  import { runSkillsImCommand } from './skills/im-command.js';
58
58
  import { fetchDaemonIpc } from './daemon-ipc-auth.js';
@@ -1381,29 +1381,43 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1381
1381
  // CLI in whatever workingDir is currently set on the session. Shared by
1382
1382
  // `commitRepoSelection` (a repo was named) and the bare-`/repo` launch
1383
1383
  // (use the default workingDir) — both only run while `pendingRepo`.
1384
- const forkPendingCli = async (replyText) => {
1384
+ const forkPendingCli = async (replyText, claimAlready = false) => {
1385
+ if (!claimAlready) {
1386
+ if (ds.pendingRepoCommitInFlight) {
1387
+ await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1388
+ return false;
1389
+ }
1390
+ ds.pendingRepoCommitInFlight = true;
1391
+ }
1385
1392
  const selfBot = getBot(ds.larkAppId);
1386
1393
  const botCfg = selfBot.config;
1387
1394
  const commitGenSessionId = ds.session.sessionId;
1388
- ds.pendingRepo = false;
1389
- publishAttentionPatch(ds);
1390
- const pendingPrompt = ds.pendingPrompt ?? '';
1391
- const pendingRawInput = ds.pendingRawInput;
1392
- // Was there an actual buffered user message to deliver? A session
1393
- // launched *via* `/repo` (the command itself is the first message) has
1394
- // none so boot the CLI idle and let the user's NEXT message be the
1395
- // first prompt, instead of submitting an empty/boilerplate user_message.
1396
- const hasBufferedInput = pendingPrompt.trim().length > 0 ||
1397
- (ds.pendingAttachments?.length ?? 0) > 0 ||
1398
- (ds.pendingFollowUps?.length ?? 0) > 0;
1399
- if (pendingRawInput) {
1400
- // Messages buffered while the repo card was pending must not be
1401
- // dropped: wrap them now (full prompt-building context lives here)
1402
- // and stash for delivery right after the raw input on prompt_ready.
1403
- if (hasBufferedInput) {
1404
- const { buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots } = await import('./session-manager.js');
1395
+ let committed = false;
1396
+ try {
1397
+ // Keep pendingRepo=true while prompt context is awaited. Incoming
1398
+ // messages stay in the same buffer and card selections see the
1399
+ // shared in-flight claim instead of treating this as a repo switch.
1400
+ const initiallyBuffered = (ds.pendingPrompt?.trim().length ?? 0) > 0 ||
1401
+ (ds.pendingAttachments?.length ?? 0) > 0 ||
1402
+ (ds.pendingFollowUps?.length ?? 0) > 0;
1403
+ const availableBots = initiallyBuffered
1404
+ ? await getAvailableBots(ds.larkAppId, ds.chatId)
1405
+ : [];
1406
+ const stillActive = activeSessions.get(sessionKey(rootId, larkAppId)) === ds;
1407
+ if (!stillActive || ds.session.sessionId !== commitGenSessionId ||
1408
+ !ds.pendingRepo || (ds.worker && !ds.worker.killed)) {
1409
+ logger.warn(`[${logTag}] Session changed while preparing the pending CLI (${commitGenSessionId} ${ds.session.sessionId}, active=${stillActive}, pending=${!!ds.pendingRepo}, worker=${!!ds.worker})`);
1410
+ return false;
1411
+ }
1412
+ const pendingPrompt = ds.pendingPrompt ?? '';
1413
+ const pendingRawInput = ds.pendingRawInput;
1414
+ const hasBufferedInput = pendingPrompt.trim().length > 0 ||
1415
+ (ds.pendingAttachments?.length ?? 0) > 0 ||
1416
+ (ds.pendingFollowUps?.length ?? 0) > 0;
1417
+ if (hasBufferedInput)
1405
1418
  ensureSessionWhiteboard(ds);
1406
- const followUpInput = buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc, ds.pendingSender, {
1419
+ const wrappedInput = hasBufferedInput
1420
+ ? buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc, ds.pendingSender, {
1407
1421
  larkAppId,
1408
1422
  chatId: ds.chatId,
1409
1423
  whiteboardId: ds.session.whiteboardId,
@@ -1413,64 +1427,82 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1413
1427
  codexAppMessageContext: ds.pendingCodexAppMessageContext,
1414
1428
  codexAppFollowUps: ds.pendingCodexAppFollowUps,
1415
1429
  codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
1416
- });
1430
+ })
1431
+ : { content: '' };
1432
+ const pendingTurnId = ds.pendingTurnId;
1433
+ const previousPendingFollowUpInput = ds.pendingFollowUpInput;
1434
+ if (pendingRawInput && hasBufferedInput) {
1417
1435
  ds.pendingFollowUpInput = {
1418
1436
  userPrompt: ds.pendingCodexAppText !== undefined || ds.pendingCodexAppFollowUps
1419
1437
  ? [ds.pendingCodexAppText ?? '', ...(ds.pendingCodexAppFollowUps ?? [])].filter(Boolean).join('\n\n')
1420
1438
  : pendingPrompt || ds.pendingFollowUps?.join('\n\n') || '',
1421
- cliInput: followUpInput.content,
1422
- ...((ds.session.cliId ?? botCfg.cliId) === 'codex-app' && botCfg.codexAppCleanInput === true && followUpInput.codexAppInput
1423
- ? { codexAppInput: followUpInput.codexAppInput }
1439
+ cliInput: wrappedInput.content,
1440
+ ...(ds.pendingFollowUpTurnId ? { turnId: ds.pendingFollowUpTurnId } : {}),
1441
+ ...((ds.session.cliId ?? botCfg.cliId) === 'codex-app' && botCfg.codexAppCleanInput === true && wrappedInput.codexAppInput
1442
+ ? { codexAppInput: wrappedInput.codexAppInput }
1424
1443
  : {}),
1425
1444
  codexAppInputGateFrozen: true,
1426
1445
  };
1427
1446
  }
1428
- rememberLastCliInput(ds, pendingRawInput, pendingRawInput);
1429
- forkWorker(ds, '', false);
1430
- }
1431
- else if (hasBufferedInput) {
1432
- const { buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots } = await import('./session-manager.js');
1433
- ensureSessionWhiteboard(ds);
1434
- const prompt = buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc, ds.pendingSender, {
1435
- larkAppId,
1436
- chatId: ds.chatId,
1437
- whiteboardId: ds.session.whiteboardId,
1438
- substituteTrigger: ds.pendingSubstituteTrigger,
1439
- codexAppText: ds.pendingCodexAppText,
1440
- codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
1441
- codexAppMessageContext: ds.pendingCodexAppMessageContext,
1442
- codexAppFollowUps: ds.pendingCodexAppFollowUps,
1443
- codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
1444
- });
1445
- // Last-line defence: prompt prep awaited above — if anything
1446
- // replaced OR closed the session in that window (`/close` deletes
1447
- // the active-map entry without touching sessionId), forking now
1448
- // would clobber it or resurrect a closed session.
1449
- const stillActive = activeSessions.get(sessionKey(rootId, larkAppId)) === ds;
1450
- if (!stillActive || ds.session.sessionId !== commitGenSessionId) {
1451
- logger.warn(`[${logTag}] Session replaced or closed while preparing the pending-CLI prompt (${commitGenSessionId} → ${ds.session.sessionId}, active=${stillActive}) — aborting this fork`);
1452
- return;
1447
+ ds.pendingRepo = false;
1448
+ publishAttentionPatch(ds);
1449
+ try {
1450
+ if (pendingRawInput)
1451
+ forkWorker(ds, '', false);
1452
+ else if (pendingTurnId && hasBufferedInput)
1453
+ forkWorker(ds, wrappedInput, { turnId: pendingTurnId });
1454
+ else if (hasBufferedInput)
1455
+ forkWorker(ds, wrappedInput);
1456
+ else
1457
+ forkWorker(ds, '', false);
1458
+ }
1459
+ catch (e) {
1460
+ ds.pendingRepo = true;
1461
+ ds.pendingFollowUpInput = previousPendingFollowUpInput;
1462
+ publishAttentionPatch(ds);
1463
+ throw e;
1464
+ }
1465
+ if (pendingRawInput || hasBufferedInput) {
1466
+ rememberLastCliInput(ds, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput);
1467
+ }
1468
+ ds.pendingPrompt = undefined;
1469
+ ds.pendingCodexAppText = undefined;
1470
+ ds.pendingCodexAppApplicationContext = undefined;
1471
+ ds.pendingCodexAppMessageContext = undefined;
1472
+ ds.pendingAttachments = undefined;
1473
+ ds.pendingMentions = undefined;
1474
+ ds.pendingSubstituteTrigger = undefined;
1475
+ ds.pendingSender = undefined;
1476
+ ds.pendingFollowUps = undefined;
1477
+ ds.pendingCodexAppFollowUps = undefined;
1478
+ ds.pendingCodexAppFollowUpContexts = undefined;
1479
+ ds.pendingFollowUpTurnId = undefined;
1480
+ ds.pendingTurnId = undefined;
1481
+ committed = true;
1482
+ // Local one-shot consume BEFORE network awaits. Feishu withdraw is
1483
+ // best-effort; stale card clicks must be rejected via this mark even
1484
+ // if sessionReply throws or deleteMessage lags/fails.
1485
+ const cardToWithdraw = ds.repoCardMessageId;
1486
+ markRepoCardConsumed(ds, cardToWithdraw);
1487
+ ds.repoCardMessageId = undefined;
1488
+ // Hold the claim through confirmation + best-effort card withdraw.
1489
+ try {
1490
+ await sessionReply(rootId, replyText);
1491
+ }
1492
+ catch (e) {
1493
+ logger.warn(`[${logTag}] Confirm reply after pending repo commit failed: ${e instanceof Error ? e.message : e}`);
1494
+ }
1495
+ if (cardToWithdraw) {
1496
+ try {
1497
+ await deleteMessage(ds.larkAppId, cardToWithdraw);
1498
+ }
1499
+ catch { /* best-effort */ }
1453
1500
  }
1454
- rememberLastCliInput(ds, pendingPrompt, prompt);
1455
- forkWorker(ds, prompt);
1456
1501
  }
1457
- else {
1458
- // Empty initial prompt → worker spawns the CLI without submitting
1459
- // anything (see worker.ts: the init prompt is only queued when truthy).
1460
- forkWorker(ds, '', false);
1502
+ finally {
1503
+ ds.pendingRepoCommitInFlight = false;
1461
1504
  }
1462
- ds.pendingPrompt = undefined;
1463
- ds.pendingCodexAppText = undefined;
1464
- ds.pendingCodexAppApplicationContext = undefined;
1465
- ds.pendingCodexAppMessageContext = undefined;
1466
- ds.pendingAttachments = undefined;
1467
- ds.pendingMentions = undefined;
1468
- ds.pendingSubstituteTrigger = undefined;
1469
- ds.pendingSender = undefined;
1470
- ds.pendingFollowUps = undefined;
1471
- ds.pendingCodexAppFollowUps = undefined;
1472
- ds.pendingCodexAppFollowUpContexts = undefined;
1473
- await sessionReply(rootId, replyText);
1505
+ return committed;
1474
1506
  };
1475
1507
  // Shared commit path for an already-resolved repo: update the session's
1476
1508
  // working dir, then either fork into the pending CLI (first spawn) or
@@ -1478,13 +1510,27 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1478
1510
  // numeric `/repo <N>` form and the `/repo <path|name>` form.
1479
1511
  const commitRepoSelection = async (selectedPath, displayName, how) => {
1480
1512
  if (ds.pendingRepo) {
1481
- // First spawn: pin the new cwd onto the CURRENT session, then fork.
1482
- ds.workingDir = selectedPath;
1483
- ds.session.workingDir = selectedPath;
1484
- // A single repo selection supersedes any stale multi-Riff stamp.
1485
- ds.session.riffRepoDirs = undefined;
1486
- sessionStore.updateSession(ds.session);
1487
- await forkPendingCli(t('cmd.repo.selected_in_pending', { name: displayName }, loc));
1513
+ if (ds.pendingRepoCommitInFlight) {
1514
+ await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1515
+ return false;
1516
+ }
1517
+ ds.pendingRepoCommitInFlight = true;
1518
+ try {
1519
+ // Claim before changing cwd so a card selection cannot overwrite
1520
+ // this choice while the pending prompt is prepared.
1521
+ ds.workingDir = selectedPath;
1522
+ ds.session.workingDir = selectedPath;
1523
+ ds.session.riffRepoDirs = undefined;
1524
+ sessionStore.updateSession(ds.session);
1525
+ // forkPendingCli owns claim release + confirm reply + card withdraw.
1526
+ if (!await forkPendingCli(t('cmd.repo.selected_in_pending', { name: displayName }, loc), true)) {
1527
+ return false;
1528
+ }
1529
+ }
1530
+ catch (e) {
1531
+ ds.pendingRepoCommitInFlight = false;
1532
+ throw e;
1533
+ }
1488
1534
  }
1489
1535
  else {
1490
1536
  // Safety net: a mid-session `/repo` switch closes the running
@@ -1498,11 +1544,15 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1498
1544
  // anchor — expected; `/close` the new one first, or use the
1499
1545
  // command.) Mirrors the `/close` case above.
1500
1546
  //
1547
+ // Claim any open repo card BEFORE killWorker / await so a concurrent
1548
+ // card click cannot double-switch while this text path runs.
1549
+ //
1501
1550
  // The new cwd is NOT written onto the old session here — it would
1502
1551
  // pollute the displaced session's stored workingDir (and the closed
1503
1552
  // card), so `claude --resume` later would reopen the old context in
1504
1553
  // the new repo's cwd. The new repo is pinned onto the fresh session
1505
1554
  // below instead.
1555
+ const claimedCard = claimCurrentRepoCard(ds, undefined);
1506
1556
  const closedCard = buildClosedSessionCard(ds, loc);
1507
1557
  killWorker(ds);
1508
1558
  sessionStore.closeSession(ds.session.sessionId);
@@ -1522,13 +1572,21 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1522
1572
  sessionStore.updateSession(ds.session);
1523
1573
  ds.hasHistory = false;
1524
1574
  forkWorker(ds, '', false);
1525
- await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc));
1526
- }
1527
- if (ds.repoCardMessageId) {
1528
- deleteMessage(ds.larkAppId, ds.repoCardMessageId);
1529
- ds.repoCardMessageId = undefined;
1575
+ try {
1576
+ await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc));
1577
+ }
1578
+ catch (e) {
1579
+ logger.warn(`[${logTag}] Confirm reply after mid-session repo switch failed: ${e instanceof Error ? e.message : e}`);
1580
+ }
1581
+ if (claimedCard) {
1582
+ try {
1583
+ await deleteMessage(ds.larkAppId, claimedCard);
1584
+ }
1585
+ catch { /* best-effort */ }
1586
+ }
1530
1587
  }
1531
1588
  logger.info(`[${logTag}] Repo selected via ${how}: ${selectedPath}`);
1589
+ return true;
1532
1590
  };
1533
1591
  // `/repo wt <N|name|path> [branch]` → create a worktree off the repo's
1534
1592
  // remote default branch and open THAT as the session repo. Without a
@@ -1563,7 +1621,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1563
1621
  }
1564
1622
  repoPath = resolved.path;
1565
1623
  }
1566
- if (ds.worktreeCreating) {
1624
+ if (ds.worktreeCreating || ds.pendingRepoCommitInFlight) {
1567
1625
  await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1568
1626
  break;
1569
1627
  }
@@ -1644,7 +1702,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1644
1702
  // would double-fork. One lock gates both kinds until the commit
1645
1703
  // settles. (Bare `/repo` without pending only posts the picker card —
1646
1704
  // harmless, so it stays open.)
1647
- if (ds?.worktreeCreating && (repoArg || ds.pendingRepo)) {
1705
+ if ((ds?.worktreeCreating || ds?.pendingRepoCommitInFlight) && (repoArg || ds?.pendingRepo)) {
1648
1706
  await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1649
1707
  break;
1650
1708
  }
@@ -1690,11 +1748,10 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1690
1748
  break;
1691
1749
  }
1692
1750
  const cwd = getSessionWorkingDir(ds);
1751
+ // bare /repo is the text twin of skip_repo: launch in the default
1752
+ // cwd without pinning it (forkPendingCli does not write workingDir).
1753
+ // Confirmation + card withdraw run under the claim inside forkPendingCli.
1693
1754
  await forkPendingCli(t('cmd.skip.opened', { cwd }, loc));
1694
- if (ds.repoCardMessageId) {
1695
- deleteMessage(ds.larkAppId, ds.repoCardMessageId);
1696
- ds.repoCardMessageId = undefined;
1697
- }
1698
1755
  logger.info(`[${logTag}] Bare /repo while pending → launch in workingDir ${cwd}`);
1699
1756
  break;
1700
1757
  }