Skip to content
Merged
5 changes: 5 additions & 0 deletions docs-site/docs/en/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Just send these commands directly in a topic, and the daemon intercepts and hand
| `/term` | Get the operable (write-enabled) terminal link for this session, delivered privately to the owner (visible-to-you in-chat, falling back to DM in topic/p2p — never exposed in the group) |
| `/quote` | Pop a picker of this chat's topics; choosing one reads that topic's transcript into the current session. This fills a gap in Feishu itself — its quote-reply UI can only reference a single message, never a whole topic. The bot replies with a short acknowledgement (how many messages, time span, subject) and waits for your next instruction |
| `/quote <instruction>` | Same, but runs your instruction as soon as you pick a topic, saving a round trip. The transcript is still injected explicitly labelled as material rather than instructions |
| `/sessions` | List this bot's active topic sessions in the current group and jump directly back to a topic (legacy sessions use a safe locate fallback) |
| `/dashboard [module]` | Open Dashboard control cards in Feishu (sessions/schedules/groups/settings/help, etc.) |
| `/insight` | owner-only: instantly posts a "session insight summary" card for the current session (aggregate metrics + rule suggestions; action-span detail / per-turn reconciliation / conversation replay live on the Dashboard "Insights" page) |
| `/vc prepare <meeting link or number>` | Use the current regular group as a meeting-prep chat and reuse the same Agent session during the meeting |
Expand All @@ -35,6 +36,10 @@ Just send these commands directly in a topic, and the daemon intercepts and hand
| `/issue done` | Run inside the task group to **accept the work** and move the task to its terminal state on the platform. An agent can only deliver up to "in review"; marking it done is a human decision. Once done, the platform clears the claim and the task can no longer be released. Also limited to the bot's `allowedUsers` |
| `/issue release` | Run inside the group created when the task was claimed: hands the task back to the platform's todo pool so someone else can take it. The group and session are **not** disbanded — the conversation is kept. Also limited to the bot's `allowedUsers` |

`/sessions` card preview:

![Current-group active topic sessions card](/img/sessions-command-card.png)

See [Session & Topic Model](/en/session-model) for the repository-picker and pinned-directory branches of bare `/t`. You can also make `/repo` the new topic's first command:

- `/t /repo <path|project name>`
Expand Down
5 changes: 5 additions & 0 deletions docs-site/docs/zh/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
| `/term` | 获取当前会话的「可操作终端」(带写权限)链接,私密发给 owner(群内仅你可见,话题/单聊回退私信,不在群里暴露) |
| `/quote` | 弹出本群话题选择卡,选一个就把那个话题的聊天记录读进当前会话。补的是飞书本身的缺口——飞书的「引用」只能引单条消息,没有「引用整个话题」的入口。读完只回一句确认(多少条、时间跨度、主题),等你下一条指令 |
| `/quote <指令>` | 同上,但选完话题直接执行你的指令,省一个来回。话题内容仍然会被明确标注为「资料而不是指令」注入 |
| `/sessions` | 列出当前机器人在本群的活跃话题会话,可直接回到原话题(旧会话安全降级为定位通知) |
| `/dashboard [模块]` | 在飞书里打开 Dashboard 控制卡片(sessions/schedules/groups/settings/help 等) |
| `/insight` | owner 专用:在当前会话即时回一张「本会话洞察摘要」卡片(聚合指标 + 规则建议;动作 span 明细 / 逐轮对账 / 对话回放在 Dashboard「洞察」页看) |
| `/vc prepare <会议链接或会议号>` | 将当前普通群设为会议准备群,并在开会后复用同一 Agent 会话 |
Expand All @@ -35,6 +36,10 @@
| `/issue done` | 在任务群里发,**验收通过**,把任务推到平台终态。agent 交付只能到「待验收」,标完成是人的决策。完成后 claim 被平台清掉,这条领取不能再释放。同样限该 bot 的 `allowedUsers` |
| `/issue release` | 在领取任务时建出来的那个群里发,把任务退回平台「待领取」,别人可以重新领。群和会话**不会自动解散**,对话记录保留。同样限该 bot 的 `allowedUsers` |

`/sessions` 卡片示意:

![当前群活跃话题会话卡片](/img/sessions-command-card.png)

裸 `/t` 的选仓/固定目录分支见[会话与话题模型](/session-model)。也可以把 `/repo` 作为新话题的首条命令:

- `/t /repo <路径|项目名>`
Expand Down
Binary file added docs-site/static/img/sessions-command-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 10 additions & 1 deletion src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { worktreeSlugFromContextAI } from '../services/worktree-slug-ai.js';
import { isRemoteBackendSession, resolvePairedSpawnBackendType } from './persistent-backend.js';
import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCard, buildSlashListCard, getCliDisplayName, buildConfigCard, buildForkPanelCard, buildAdoptBlockedCard } from '../im/lark/card-builder.js';
import { handleDashboardCommand } from './dashboard-command/index.js';
import { handleGroupSessionsCommand } from './group-sessions-command.js';
import { createCliAdapterSync } from '../adapters/cli/registry.js';
import type { CliId, ResumableSession } from '../adapters/cli/types.js';
import { resolveCliRuntime, runtimeInstallationKey, snapshotCliRuntime } from '../adapters/cli/runtime.js';
Expand Down Expand Up @@ -131,7 +132,7 @@ export { DAEMON_COMMANDS, PASSTHROUGH_COMMANDS };
* card buttons routable, but for these that record is a phantom conversation
* that pollutes the dashboard's session list. Handle them without a session.
*/
export const SESSIONLESS_DAEMON_COMMANDS = new Set(['/group', '/g', '/list-slash-command', '/slash', '/botconfig', '/dashboard', '/skills', '/vc-auth', '/watch-comment', '/issue']);
export const SESSIONLESS_DAEMON_COMMANDS = new Set(['/group', '/g', '/list-slash-command', '/slash', '/botconfig', '/dashboard', '/sessions', '/skills', '/vc-auth', '/watch-comment', '/issue']);

const SLASH_GROUP_NAME_MAX_UTF16_LENGTH = 50;

Expand Down Expand Up @@ -2673,6 +2674,13 @@ export async function handleCommand(
break;
}

case '/sessions': {
const chatId = ds?.chatId ?? message.chatId ?? '';
await handleGroupSessionsCommand(message, rootId, chatId, deps, larkAppId);
logger.info(`[${logTag}] Current-group sessions command handled`);
break;
}

case '/role': {
const chatId = ds?.chatId;
if (!chatId || !larkAppId) {
Expand Down Expand Up @@ -4620,6 +4628,7 @@ export async function handleCommand(
t('help.cot', undefined, loc),
t('help.term', undefined, loc),
t('help.quote', undefined, loc),
t('help.sessions', undefined, loc),
t('help.dashboard', undefined, loc),
t('help.issue', undefined, loc),
t('help.insight', undefined, loc),
Expand Down
135 changes: 122 additions & 13 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,16 @@ import {
updateTaskWithOptionalPrecondition,
type SchedulePreconditionMutation,
} from './schedule-precondition-config.js';
import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker, latestPerBotEnvForRestart, latestModelForRespawn, getDaemonReplyCardUsageSnapshot, sessionSupportsWebTerminal, sendWorkerSessionInput, isSessionTransferring, mojoCloseResidualForRow, getDaemonBootId } from './worker-pool.js';
import { listActiveSessions, findActiveBySessionId, closeSession, getActiveSessionsRegistry, transferSession, deliverWriteLinkCardToOwners, forkWorker, suspendWorker, killWorker, latestPerBotEnvForRestart, latestModelForRespawn, getDaemonReplyCardUsageSnapshot, sessionSupportsWebTerminal, sendWorkerSessionInput, isSessionTransferring, mojoCloseResidualForRow, getDaemonBootId, CARD_POSTING_SENTINEL } from './worker-pool.js';
import { listOnlineDaemons } from '../utils/daemon-discovery.js';
import { isSessionStopped } from './session-liveness.js';
import { isRemoteBackendType, isRemoteCliId, isSuspendableBackendType } from './persistent-backend.js';
import { getChatMode, replyMessage, sendMessage, resolveUnionIdFromOpenId, listThreadMessages, listChatMessages, listChatMessagesUntil, listChatBotMembers, getUserProfile, getUserProfileStrict, resolveAllowedUsersWithMap, type ChatBotMember } from '../im/lark/client.js';
import { getChatMode, replyMessage, sendMessage, resolveUnionIdFromOpenId, listThreadMessages, listChatMessages, listChatMessagesUntil, listChatBotMembers, getUserProfile, getUserProfileStrict, resolveAllowedUsersWithMap, getMessageThreadId, type ChatBotMember } from '../im/lark/client.js';
import { fillNativeTopicId, isNativeTopicId } from './native-topic-id.js';
import { publishNativeTopicLinkPatchForSession } from './session-activity.js';
import { parseApiMessage, cardContentHasUpgradeFallback, resolveMergedCardContent, messageMentionsBot } from '../im/lark/message-parser.js';
import { resumeSession, spawnDashboardSession, activateQueuedSession, closeCliMismatchedSessionsForBot } from './session-manager.js';
import { reconcileResumedStreamingCard } from './resume-streaming-card.js';

import { parseSpawnRequest } from './session-create.js';
import { cleanupMaterializedDashboardImages, materializeDashboardImages } from './dashboard-images.js';
Expand All @@ -139,6 +142,7 @@ import { locateLimiter } from './dashboard-locate.js';
import { DEFAULT_SESSION_OWNER_REMINDER } from './session-owner-reminder.js';
import { updateSessionOwnerReminderConfig } from '../services/session-owner-reminder-config-store.js';
import { sendSessionOwnerThreadNotification } from '../services/session-owner-notification.js';
import { matchesExpectedSessionLocateScope, type SessionLocateExpectedScope } from './session-locate-guard.js';
import { buildTerminalUrl } from './terminal-url.js';
import { dashboardEventBus } from './dashboard-events.js';
import { validateWorkingDir } from './working-dir.js';
Expand Down Expand Up @@ -1192,6 +1196,46 @@ ipcRoute('GET', '/api/sessions/:sessionId', (_req, res, params) => {
jsonRes(res, 404, { error: 'not_found' });
});

const topicIdResolveInFlight = new Map<string, Promise<{ ok: boolean; status: string }>>();
const topicIdResolveCooldownUntil = new Map<string, number>();
const TOPIC_ID_RESOLVE_COOLDOWN_MS = 5 * 60_000;

ipcRoute('POST', '/api/sessions/:sessionId/resolve-thread-id', async (_req, res, params) => {
const sessionId = params.sessionId;
const existing = topicIdResolveInFlight.get(sessionId);
if (existing) return jsonRes(res, 200, await existing);
const task = (async () => {
const session = findOwnedSessionRecord(sessionId);
if (!session) return { ok: false, status: 'not_found' };
if (session.scope !== 'thread' || !/^om_[A-Za-z0-9_-]+$/.test(session.rootMessageId)) {
return { ok: true, status: 'ineligible' };
}
if (isNativeTopicId(session.larkThreadId)) return { ok: true, status: 'already_present' };
if ((topicIdResolveCooldownUntil.get(sessionId) ?? 0) > Date.now()) return { ok: true, status: 'unresolved' };
let nativeId: string | null;
try {
nativeId = await getMessageThreadId(session.larkAppId || cachedLarkAppId, session.rootMessageId);
} catch {
topicIdResolveCooldownUntil.set(sessionId, Date.now() + TOPIC_ID_RESOLVE_COOLDOWN_MS);
return { ok: false, status: 'unresolved' };
}
// Re-read after the remote call: a normal inbound event may have filled it.
const current = findOwnedSessionRecord(sessionId);
if (!current) return { ok: false, status: 'not_found' };
if (isNativeTopicId(current.larkThreadId)) return { ok: true, status: 'already_present' };
if (!fillNativeTopicId(current, 'thread', nativeId)) {
topicIdResolveCooldownUntil.set(sessionId, Date.now() + TOPIC_ID_RESOLVE_COOLDOWN_MS);
return { ok: true, status: 'unresolved' };
}
sessionStore.updateSession(current);
publishNativeTopicLinkPatchForSession(current);
topicIdResolveCooldownUntil.delete(sessionId);
return { ok: true, status: 'resolved' };
})();
topicIdResolveInFlight.set(sessionId, task);
try { return jsonRes(res, 200, await task); } finally { topicIdResolveInFlight.delete(sessionId); }
});

/** Low-frequency card-display read used by `botmux send`. Keeping the
* transcript reader and per-bot visibility decision in the resident daemon
* preserves its incremental cache and live config instead of making every
Expand Down Expand Up @@ -2863,6 +2907,15 @@ function workingDirForSession(sessionId: string): string | undefined {
* the original Lark thread so users see why the session is alive again.
*/
ipcRoute('POST', '/api/sessions/:sessionId/resume', async (req, res, params) => {
const parsedBody = await readJsonBody<unknown>(req, 4_096).catch(() => ({}));
// JSON accepts null, arrays, and scalars. Treat all non-record input as an
// empty option bag before resuming, so a malformed optional body can never
// turn a successful state transition into a 500 below.
const body: { reconcileStreamingCard?: unknown } = parsedBody !== null
&& typeof parsedBody === 'object'
&& !Array.isArray(parsedBody)
? parsedBody as { reconcileStreamingCard?: unknown }
: {};
const sessionId = params.sessionId;
const sourceSession = findSessionRecord(sessionId);
if (!sourceSession) return jsonRes(res, 404, { ok: false, error: 'not_found' });
Expand Down Expand Up @@ -2907,17 +2960,47 @@ ipcRoute('POST', '/api/sessions/:sessionId/resume', async (req, res, params) =>
const cliName = sessionConfiguredRuntimeDisplayName(ds.session, botCfg?.cliRuntime)
?? getCliDisplayName(cliId ?? botCfg?.cliId ?? 'claude-code');
const notice = JSON.stringify({ text: `🔄 会话已通过命令行恢复,发条消息继续与 ${cliName} 对话。` });
if (ds.larkAppId && !sessionTransportDisabled(ds)) {
if (ds.scope === 'chat' && ds.chatId) {
getChatMode(ds.larkAppId, ds.chatId, { forceRefresh: true })
.then((mode) => mode === 'topic' && ds.session.rootMessageId
? replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
: sendMessage(ds.larkAppId, ds.chatId, notice, 'text'))
.catch(err => logger.debug(`[resume] failed to post chat-scope resume notice: ${err}`));
} else if (ds.session.rootMessageId) {
replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
.catch(err => logger.debug(`[resume] failed to post thread-scope resume notice: ${err}`));
const postResumeNotice = async (): Promise<void> => {
if (!ds.larkAppId) return;
if (!sessionTransportDisabled(ds)) {
if (ds.scope === 'chat' && ds.chatId) {
await getChatMode(ds.larkAppId, ds.chatId, { forceRefresh: true })
.then((mode) => mode === 'topic' && ds.session.rootMessageId
? replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
: sendMessage(ds.larkAppId, ds.chatId, notice, 'text'))
.catch(err => logger.debug(`[resume] failed to post chat-scope resume notice: ${err}`));
} else if (ds.session.rootMessageId) {
await replyMessage(ds.larkAppId, ds.session.rootMessageId, notice, 'text', true)
.catch(err => logger.debug(`[resume] failed to post thread-scope resume notice: ${err}`));
}
}
};

// `/sessions` resumes from a card outside the original topic, so there is no
// clicked in-topic card callback to replace the stale "session closed" card.
// Opt in explicitly from that caller: publish the fresh waiting card first,
// then withdraw its predecessor. Keep CLI/dashboard resume behavior stable.
const staleCardId = ds.streamCardId;
const shouldReconcileStreamingCard = body.reconcileStreamingCard === true
&& ds.scope === 'thread'
&& !!ds.session.rootMessageId
&& !!staleCardId
&& staleCardId !== CARD_POSTING_SENTINEL
&& botCfg?.privateCard !== true;
if (shouldReconcileStreamingCard) {
const rootMessageId = ds.session.rootMessageId!;
void reconcileResumedStreamingCard(
ds,
staleCardId,
cardJson => replyMessage(ds.larkAppId, rootMessageId, cardJson, 'interactive', true),
).then(async (result) => {
if (result.status === 'committed') await postResumeNotice();
}).catch(async (err) => {
logger.warn(`[resume] failed to reconcile original streaming card: ${err instanceof Error ? err.message : String(err)}`);
await postResumeNotice();
});
} else {
void postResumeNotice();
}

// Report the EFFECTIVE action, not the raw request flag: only fork when wake
Expand Down Expand Up @@ -3062,8 +3145,21 @@ ipcRoute('POST', '/api/sessions/migrate-to-chat', async (req, res) => {
jsonRes(res, 200, { ok: true, sessionId: ds.session.sessionId });
});

ipcRoute('POST', '/api/sessions/:sessionId/locate', async (_req, res, params) => {
ipcRoute('POST', '/api/sessions/:sessionId/locate', async (req, res, params) => {
const sid = params.sessionId;
let body: unknown;
try {
body = await readJsonBody(req, 8 * 1024);
} catch (err) {
return jsonRes(res, err instanceof JsonBodyTooLargeError ? 413 : 400, {
ok: false,
error: err instanceof JsonBodyTooLargeError ? 'body_too_large' : 'invalid_json',
});
}
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return jsonRes(res, 400, { ok: false, error: 'body_must_be_object' });
}
const expected = body as SessionLocateExpectedScope;
const acq = locateLimiter.tryAcquire(sid);
if (!acq.ok) {
res.writeHead(429, {
Expand All @@ -3082,19 +3178,32 @@ ipcRoute('POST', '/api/sessions/:sessionId/locate', async (_req, res, params) =>
const ctx = ds
? {
larkAppId: ds.larkAppId,
chatId: ds.chatId,
scope: ds.session.scope,
status: ds.session.status,
rootMessageId: ds.session.rootMessageId,
ownerOpenId: ds.session.ownerOpenId,
}
: closed
? {
larkAppId: closed.larkAppId ?? '',
chatId: closed.chatId,
scope: closed.scope,
status: closed.status,
rootMessageId: closed.rootMessageId,
ownerOpenId: closed.ownerOpenId,
}
: null;
if (!ctx || !ctx.larkAppId) {
return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
}
// Optional compare-before-locate guard used by the public `/sessions` card.
// Existing dashboard callers send `{}` and keep their historical behavior.
// When present, every field is checked against the daemon's latest row so a
// transfer/close racing the card handler's fresh GET fails closed here.
if (!matchesExpectedSessionLocateScope(ctx, expected)) {
return jsonRes(res, 409, { ok: false, error: 'session_scope_changed' });
}
if (!ctx.ownerOpenId) {
return jsonRes(res, 422, { ok: false, error: 'no_owner' });
}
Expand Down
Loading