From 05c26f2c440d5c79b8f804fac75607f6a8073b15 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 14:12:45 +0800 Subject: [PATCH 01/69] feat(kernel): bind Feishu groups to per-group workspaces One bot, N groups. Each group binds to a workspace directory (multi-repo container) with an active (repo, branch) pointer. Threads inside a group become isolated sessions that follow the live binding at every dispatch. - group_workspaces table + GroupWorkspaceStore (CRUD + cwd/env resolver) - Gateway slash commands that bypass the LLM: /bind /unbind /status /ls /clone /checkout /help - Feishu channel carries chat_id + thread_id on UserMessage; deterministic session_id = feishu:: when both known - AgentRunOptions.envExtras threaded through Claude and Codex runners so DEV_ASSETS_PRIMARY_REPO / DEV_ASSETS_PRIMARY_BRANCH reach the CLI - Boot-loader ensures workspaces/ and workspaces/_default/ exist - Default workspace fallback when a group is unbound Design: docs/designs/group-workspace-binding.md Requires the branch-context-skill-suite workspace mode for dev-assets memory integration across multiple repos. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/designs/group-workspace-binding.md | 210 ++++++++++++ drizzle/0010_lean_enchantress.sql | 11 + drizzle/meta/0010_snapshot.json | 309 ++++++++++++++++++ drizzle/meta/_journal.json | 9 +- src/boot-loader/boot-loader.ts | 6 + .../anthropic/claude-agent-runner.ts | 1 + .../feishu/messaging/message-channel.ts | 35 +- src/community/openai/codex-agent-runner.ts | 1 + src/kernel/commands/handlers.ts | 248 ++++++++++++++ src/kernel/commands/index.ts | 4 + src/kernel/commands/parser.ts | 23 ++ src/kernel/commands/registry.ts | 26 ++ src/kernel/commands/types.ts | 35 ++ src/kernel/kernel.ts | 78 ++++- src/kernel/sessioning/data/schema.ts | 27 ++ src/kernel/sessioning/session-manager.ts | 23 ++ src/kernel/workspaces/index.ts | 1 + src/kernel/workspaces/store.ts | 173 ++++++++++ src/shared/agents/agent-runner.ts | 7 + src/shared/config/paths.ts | 10 + src/shared/index.ts | 1 + src/shared/messaging/types/message.ts | 4 + src/shared/sessioning/types/session.ts | 4 + src/shared/workspaces/index.ts | 1 + .../workspaces/types/group-workspace.ts | 26 ++ src/shared/workspaces/types/index.ts | 1 + 26 files changed, 1268 insertions(+), 6 deletions(-) create mode 100644 docs/designs/group-workspace-binding.md create mode 100644 drizzle/0010_lean_enchantress.sql create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 src/kernel/commands/handlers.ts create mode 100644 src/kernel/commands/index.ts create mode 100644 src/kernel/commands/parser.ts create mode 100644 src/kernel/commands/registry.ts create mode 100644 src/kernel/commands/types.ts create mode 100644 src/kernel/workspaces/index.ts create mode 100644 src/kernel/workspaces/store.ts create mode 100644 src/shared/workspaces/index.ts create mode 100644 src/shared/workspaces/types/group-workspace.ts create mode 100644 src/shared/workspaces/types/index.ts diff --git a/docs/designs/group-workspace-binding.md b/docs/designs/group-workspace-binding.md new file mode 100644 index 0000000..d622fae --- /dev/null +++ b/docs/designs/group-workspace-binding.md @@ -0,0 +1,210 @@ +# Group Workspace Binding Design + +Enable one agentara bot to serve multiple Feishu groups. Each group binds to its own workspace (a multi-repo directory) with an active `(repo, branch)` pointer. Every Feishu topic inside a group becomes an isolated session that follows the group's live binding. + +## Goals + +- One bot process, N Feishu groups — registered dynamically at runtime +- Per-group workspace directory (can host multiple cloned repos, all visible to the agent in one session) +- Per-topic session within a group (flexible + isolated) +- Gateway-level slash commands that bypass the LLM +- Default workspace fallback when a group is not bound +- `dev-assets` memory works across all repos in the workspace (requires the suite-side workspace mode — see `branch-context-skill-suite/docs/workspace-mode.md`) + +## Non-Goals + +- Per-topic workspace override (binding lives at group level only) +- Multiple simultaneously-active repos inside one workspace +- Rich card UI for `/init` in v1 (text commands first, card in v2) + +## Data Model + +### `group_workspaces` (new table) + +| Field | Type | Notes | +|-------|------|-------| +| `chat_id` | text, PK | Feishu `chat_id` | +| `workspace_path` | text | Absolute path, always `$AGENTARA_HOME/workspaces//` | +| `active_repo` | text, nullable | Directory name under `workspace_path` | +| `active_branch` | text, nullable | Git branch name | +| `created_at` | int | epoch ms | +| `updated_at` | int | epoch ms | + +Row absence = group is unbound. `active_repo = null` while `workspace_path` exists = workspace reserved but no repo selected yet. + +### `sessions` (schema extension) + +Add two fields to the existing `Session` schema (`src/shared/sessioning/types/session.ts`): + +- `chat_id: string | null` — owning Feishu group +- `thread_id: string | null` — Feishu topic root message id + +Keep `cwd` for backward compatibility, but **it is now a hint, not a source of truth**. Actual cwd is resolved dynamically on every dispatch (see Resolution Flow). + +### Default workspace + +Path: `$AGENTARA_HOME/workspaces/_default/` + +Created by boot-loader on first run. Used when a message arrives from an unbound group or from a group whose binding has `active_repo = null`. + +## Feishu Channel Rework + +### Current (`src/kernel/kernel.ts:87-99`) + +`config.yaml` lists each Feishu channel; one `FeishuMessageChannel` instance per chat, constructor-bound to a specific `chat_id`. Adding a group = editing config + restart. + +### New + +- Exactly one `FeishuMessageChannel` instance, **no `chat_id` filter** +- Subscribes to all group events the bot is a member of (Feishu WS IM event stream already broadcasts to the bot user; filter only on event type, not chat) +- Inbound event carries `chat_id` + `root_id` (topic id when the message is inside a topic; equals message id otherwise) +- `UserMessage.channel_id` remains the agentara internal channel id (single value for the one Feishu channel); the Feishu `chat_id` and `root_id` are carried as new fields on `UserMessage` + +### Session id derivation + +``` +session_id = `feishu:${chat_id}:${thread_id}` +``` + +- First message inside a topic → `resolveSession` creates +- Subsequent messages in same topic → `resolveSession` resumes +- Messages outside any topic (direct `@mention` with no threading) → treated as topic whose `thread_id = message_id` (each becomes its own fresh session; encourages the bot to always reply in a topic so follow-ups resume correctly) + +## Gateway Commands + +Parsed in `Kernel._handleInboundMessage` (`src/kernel/kernel.ts:114`) before `TaskDispatcher.dispatch()`. Prefix: `/` (matches Claude Code convention). Commands execute synchronously, reply via `_messageGateway.replyMessage`, do not dispatch an LLM task. + +| Command | Scope | Effect | +|---------|-------|--------| +| `/init` | group | Reply with an interactive card. User picks repo + branch; card submit writes group binding. v1 may fall back to `/bind`. | +| `/bind ` | group | Upsert `group_workspaces` row, set `active_repo` + `active_branch`. Rejects if `` not in workspace (suggests `/clone`). | +| `/unbind` | group | Delete `group_workspaces` row. | +| `/status` | group + topic | Show group binding, list cloned repos, show current topic's `session_id`. | +| `/clone [name]` | group | `git clone` into `workspace_path`. Rejects on name collision. | +| `/checkout ` | group | `git checkout` in active repo; updates `active_branch`. Rejects on dirty tree. | +| `/ls` | group | List directories under `workspace_path`. | +| `/new` | topic | Archive current session for this topic; next message opens a new session with the same `session_id` reset (or appends a generation suffix). | +| `/agent ` | topic | Overrides `agent_type` for the current session only; re-resolve session with new runner type. | + +Already-implemented reference: `/stop` (`src/kernel/kernel.ts:118`). Extend the same if-chain into a command table. + +Card interaction for `/init` should mirror the streaming card pattern in `remote_claude/lark_client/shared_memory_poller.py` + interactive element handling in Feishu card-action callbacks. Deferred to v2. + +## Resolution Flow + +Per inbound Feishu message: + +1. `FeishuMessageChannel` emits `message:inbound` with `{ chat_id, thread_id, text, ... }` +2. `Kernel._handleInboundMessage`: + - If text starts with `/` → dispatch to gateway command handler, reply, return + - Else compute `session_id` from `(chat_id, thread_id)` + - `TaskDispatcher.dispatch(session_id, { type: "inbound_message", message })` +3. `Kernel._handleInboundMessageTask`: + - Load `group_workspaces` row for `chat_id` + - Resolve cwd: + - No row → `cwd = $AGENTARA_HOME/workspaces/_default/` + - Otherwise → `cwd = workspace_path` (the workspace root, multi-repo container — **not** a single repo dir) + - If `active_repo` set: pre-checkout `git -C / checkout ` (idempotent) + - Build runner env extras: + - If `active_repo` set: `DEV_ASSETS_PRIMARY_REPO=`, `DEV_ASSETS_PRIMARY_BRANCH=` + - Else: omit (skill suite enters workspace mode without primary hint) + - `SessionManager.resolveSession(session_id, { cwd, chatId, threadId, envExtras, firstMessage })` + - `session.stream(message)` as today + +The cwd passed to `resolveSession` on resume overrides the stored `cwd` (supported today at `src/kernel/sessioning/session-manager.ts:174`). This is the mechanism for "live binding": each dispatch re-resolves. `envExtras` is a new option threaded through to the runner's `Bun.spawn` call so per-group env vars reach the underlying CLI. + +## dev-assets Integration + +The cwd contract changed: cwd is the group's workspace root (`workspaces//`), which is **not** a git repo but contains N cloned repos as first-level subdirectories. The native `dev-assets` skills assume cwd is inside a single git repo, so the suite needs an additive **workspace mode**. Suite-side design lives in `branch-context-skill-suite/docs/workspace-mode.md`. Summary: + +- `lib/dev_asset_common.py` gains `detect_workspace_mode()` + `list_repos_in_workspace()` (purely additive) +- Hook scripts (`session_start.py`, `stop.py`, `pre_compact.py`, `session_end.py`) take a workspace branch: when cwd has no `.git` but first-level subdirs do, iterate all repos +- Single-repo cwd behavior is unchanged; existing installations see no difference + +### agentara-side contract + +When dispatching, agentara passes to the runner: + +- `cwd = workspaces//` (workspace root) +- env `DEV_ASSETS_PRIMARY_REPO=` (basename) — focus repo from group binding +- env `DEV_ASSETS_PRIMARY_BRANCH=` — informational + +The skill suite then: + +- **SessionStart**: full memory for primary repo + brief overview-only summary for other repos in workspace +- **Stop / SessionEnd**: record HEAD for all repos in workspace (cheap, idempotent) +- **dev-assets-sync** (LLM-invoked): defaults to primary when no `--repo` flag; LLM can pass `--repo=` to write to another repo +- **dev-assets-context** (LLM-invoked): can target a non-primary repo via `--repo=` to load its full memory mid-session + +Codex parity: SessionStart + Stop hooks fire identically. Codex lacks PreCompact/SessionEnd, but those don't affect multi-repo correctness — only Claude-only checkpoint refinement. + +### Default workspace + +For `_default` (no binding): no `DEV_ASSETS_PRIMARY_REPO` set. Suite enters workspace mode and lists whatever repos the user happens to have cloned in there. Empty `_default` → suite no-ops (acceptable). + +### Cross-group memory sharing + +Memory is keyed by `(repo_identity, branch)` derived from git remote URL (or local path fallback). Two groups bound to the same `(repo, branch)` automatically share memory under `~/.dev-assets/repos//branches//` — no agentara code involved. + +## Concurrency & Ordering + +- Same `(chat_id, thread_id)` → same `session_id` → serial execution via `TaskDispatcher`'s per-session queue (existing guarantee). +- Different topics in same group → different `session_id` → parallel execution (existing guarantee). +- `/checkout` interleaved with an in-flight LLM turn in another topic of the same group: the running runner's `cwd` (workspace root) is unchanged, but the active repo's HEAD shifts under it. Tool calls touching `//**` may see the new branch's files mid-turn. Acceptable per Q6(b) — live binding is the user's explicit choice. Other repos in the workspace are unaffected. +- Two simultaneous `/checkout` on same group: serialize via a per-chat mutex in the gateway command layer (reuse `TaskDispatcher` isn't right — commands are synchronous). Simple `Map` mutex. + +## Error Handling + +Strict; no silent coercion. + +- `/bind ` where `` directory absent → reply with suggestion to run `/clone`. +- `/clone` with name collision → reject. +- `/checkout` on dirty tree → reply with `git status --short` output; require user to resolve manually. +- `/unbind` when no row → reply "group is not bound". +- Inbound message from group with no binding → silently fall back to `_default`, no error. +- Feishu event missing `chat_id` → log error, drop event. +- Gateway command execution failure → reply with error text; do not dispatch LLM fallback. + +## Config Changes + +`config.yaml`: + +- `messaging.channels` entries for Feishu no longer need `chat_id` (ignored if present, logged as deprecation) +- Add optional `messaging.default_workspace_path` to override `$AGENTARA_HOME/workspaces/_default/` + +No breaking change for users with existing single-chat config — a missing `chat_id` is now valid and means "listen to all groups". + +## Files Touched + +| File | Change | +|------|--------| +| `src/shared/sessioning/types/session.ts` | Add `chat_id`, `thread_id` fields | +| `src/kernel/sessioning/data.ts` | Drizzle schema: add columns + new `group_workspaces` table | +| `src/kernel/sessioning/session-manager.ts` | Pass through new fields on create/resume | +| `src/kernel/workspaces/` (new dir) | `GroupWorkspaceStore` (CRUD over `group_workspaces`), cwd resolver | +| `src/kernel/commands/` (new dir) | Slash command parser + handlers | +| `src/community/feishu/messaging/` | Remove per-chat filter; forward `chat_id` + `root_id` on inbound | +| `src/kernel/kernel.ts` | Wire command parser into `_handleInboundMessage`; wire cwd resolver + env extras into `_handleInboundMessageTask` | +| `src/shared/agents/agent-runner.ts` | Add `envExtras?: Record` to `AgentRunOptions` schema | +| `src/community/anthropic/claude-agent-runner.ts` | Merge `envExtras` into `Bun.spawn` env (preserve `ANTHROPIC_API_KEY: ""` clear for subscription auth) | +| `src/community/openai/codex-agent-runner.ts` | Merge `envExtras` into `Bun.spawn` env | +| `src/shared/messaging/types/message.ts` | Extend `UserMessage` with `chat_id`, `thread_id` optional fields | +| `src/boot-loader/boot-loader.ts` | Ensure `workspaces/_default/` exists on first run | +| `docs/overview.md` | Append "Group Workspace Binding" section linking here | + +## Implementation Order + +1. Schema additions (`group_workspaces` table, `Session` fields, `UserMessage` fields) + migration +2. `GroupWorkspaceStore` CRUD + cwd resolver +3. Feishu channel multi-group refactor (forward `chat_id` + `root_id`) +4. Session id derivation in kernel from `(chat_id, thread_id)` +5. Dynamic cwd resolution at dispatch +6. Slash command parser + core handlers (`/bind`, `/unbind`, `/status`, `/clone`, `/checkout`, `/ls`) +7. `/new`, `/agent` — session lifecycle commands +8. Default workspace bootstrap +9. v2: `/init` Feishu card interaction + +## Open Questions + +- Feishu `root_id` behavior when the bot itself opens a topic vs when user does: verify the id is stable across the whole thread. (Implementation detail; does not change design.) +- `/new` semantics: does archiving mean deleting the JSONL or renaming? Propose rename to `.archived-.jsonl` so the DB row is kept for audit. Decide during implementation. diff --git a/drizzle/0010_lean_enchantress.sql b/drizzle/0010_lean_enchantress.sql new file mode 100644 index 0000000..a422cf1 --- /dev/null +++ b/drizzle/0010_lean_enchantress.sql @@ -0,0 +1,11 @@ +CREATE TABLE `group_workspaces` ( + `chat_id` text PRIMARY KEY NOT NULL, + `workspace_path` text NOT NULL, + `active_repo` text, + `active_branch` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `sessions` ADD `chat_id` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `thread_id` text; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..5689be1 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,309 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9c5034a3-52eb-43ee-a6c8-f2d11a99d8b6", + "prevId": "43a71028-b277-4802-becc-9bc794d5744d", + "tables": { + "group_workspaces": { + "name": "group_workspaces", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_path": { + "name": "workspace_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_repo": { + "name": "active_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_branch": { + "name": "active_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 13e191a..2a72ca4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1773500000000, "tag": "0009_blue_proteus", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1776319736541, + "tag": "0010_lean_enchantress", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/boot-loader/boot-loader.ts b/src/boot-loader/boot-loader.ts index ce53dda..7da35b3 100644 --- a/src/boot-loader/boot-loader.ts +++ b/src/boot-loader/boot-loader.ts @@ -44,6 +44,12 @@ class BootLoader { if (!existsSync(config.paths.outputs)) { mkdirSync(config.paths.outputs, { recursive: true }); } + if (!existsSync(config.paths.workspaces)) { + mkdirSync(config.paths.workspaces, { recursive: true }); + } + if (!existsSync(config.paths.default_workspace)) { + mkdirSync(config.paths.default_workspace, { recursive: true }); + } if (!existsSync(config.paths.memory)) { mkdirSync(config.paths.memory, { recursive: true }); diff --git a/src/community/anthropic/claude-agent-runner.ts b/src/community/anthropic/claude-agent-runner.ts index 36c9b0f..00a8ed3 100644 --- a/src/community/anthropic/claude-agent-runner.ts +++ b/src/community/anthropic/claude-agent-runner.ts @@ -53,6 +53,7 @@ export class ClaudeAgentRunner implements AgentRunner { cwd: options.cwd, env: { ...Bun.env, + ...(options.envExtras ?? {}), ANTHROPIC_API_KEY: "", }, stderr: "pipe", diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 752580c..e321146 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -547,12 +547,19 @@ export class FeishuMessageChannel private _handleMessageReceive = async ({ message: receivedMessage, }: MessageReceiveEventData) => { - const { message_id: messageId, thread_id: threadId } = receivedMessage; - const session_id = this._resolveSessionId(threadId); + const { + message_id: messageId, + thread_id: threadId, + chat_id: chatId, + } = receivedMessage; + const session_id = this._resolveSessionId(chatId, threadId); const userMessage: UserMessage = { id: messageId, session_id, role: "user", + channel_id: this.id, + chat_id: chatId, + thread_id: threadId, content: [ await this._parseMessageContent( messageId, @@ -591,8 +598,23 @@ export class FeishuMessageChannel .run(); } - /** Resolve a session ID from a thread ID, falling back to DB then generating a new one. */ - private _resolveSessionId(threadId: string | undefined): string { + /** + * Resolve session id for an inbound Feishu message. + * + * Lookup order: + * 1. In-memory thread→session cache + * 2. `feishu_threads` DB mapping (populated when the bot replies and Feishu + * creates a new thread — see `_mapThreadToSession`) + * 3. When both chat_id + thread_id are known, derive deterministically as + * `feishu::` and persist that mapping so subsequent lookups + * short-circuit. + * 4. Fall back to `uuid()` when no thread_id (first @mention outside any + * topic). + */ + private _resolveSessionId( + chatId: string | undefined, + threadId: string | undefined, + ): string { if (threadId && this._threadIdToSessionId.has(threadId)) { return this._threadIdToSessionId.get(threadId)!; } @@ -606,6 +628,11 @@ export class FeishuMessageChannel this._threadIdToSessionId.set(threadId, row.session_id); return row.session_id; } + if (chatId) { + const derived = `feishu:${chatId}:${threadId}`; + this._mapThreadToSession(threadId, derived); + return derived; + } } return uuid(); } diff --git a/src/community/openai/codex-agent-runner.ts b/src/community/openai/codex-agent-runner.ts index 7cfb9fd..010a8d0 100644 --- a/src/community/openai/codex-agent-runner.ts +++ b/src/community/openai/codex-agent-runner.ts @@ -63,6 +63,7 @@ export class CodexAgentRunner implements AgentRunner { cwd: options.cwd, env: { ...Bun.env, + ...(options.envExtras ?? {}), }, stderr: "pipe", }); diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts new file mode 100644 index 0000000..793858b --- /dev/null +++ b/src/kernel/commands/handlers.ts @@ -0,0 +1,248 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +import type { CommandContext, CommandHandler } from "./types"; + +/** + * Non-LLM commands that run inside `_handleInboundMessage` before the + * TaskDispatcher. All commands are scoped to the current Feishu `chat_id` + * unless noted. Missing `chat_id` means the message came from a source + * that doesn't support group bindings; we reject those commands politely. + */ + +function requireChatId(ctx: CommandContext): string | null { + const chatId = ctx.message.chat_id; + if (!chatId) { + return null; + } + return chatId; +} + +async function execGit( + args: string[], + cwd: string, +): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code }; +} + +const bindHandler: CommandHandler = { + name: "bind", + description: "/bind — bind current group to a repo + branch", + async execute(ctx) { + const chatId = requireChatId(ctx); + if (!chatId) return "❌ /bind requires a Feishu group context."; + const [repo, branch] = ctx.args; + if (!repo || !branch) { + return "Usage: `/bind `\nHint: clone first via `/clone `."; + } + const resolution = ctx.workspaceStore.resolve(chatId); + const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; + const repoPath = join(workspacePath, repo); + if (!existsSync(repoPath) || !existsSync(join(repoPath, ".git"))) { + return `❌ \`${repo}\` not found under workspace \`${workspacePath}\`. Run \`/clone \` first, or check \`/ls\`.`; + } + const checkout = await execGit(["checkout", branch], repoPath); + if (!checkout.ok) { + return `❌ \`git checkout ${branch}\` failed:\n\`\`\`\n${checkout.stderr || checkout.stdout}\n\`\`\``; + } + const binding = ctx.workspaceStore.upsertBinding(chatId, { + active_repo: repo, + active_branch: branch, + }); + ctx.logger.info({ chat_id: chatId, binding }, "group binding updated"); + return [ + `✅ Bound group to \`${repo}\` @ \`${branch}\``, + `Workspace: \`${binding.workspace_path}\``, + `Next messages will use this repo+branch until \`/unbind\` or another \`/bind\`.`, + ].join("\n"); + }, +}; + +const unbindHandler: CommandHandler = { + name: "unbind", + description: "/unbind — clear this group's binding (fall back to default workspace)", + async execute(ctx) { + const chatId = requireChatId(ctx); + if (!chatId) return "❌ /unbind requires a Feishu group context."; + const removed = ctx.workspaceStore.deleteBinding(chatId); + if (!removed) return "ℹ️ This group is not bound."; + return "✅ Group binding cleared. Future messages will use the default workspace."; + }, +}; + +const statusHandler: CommandHandler = { + name: "status", + description: "/status — show current group binding + cloned repos", + async execute(ctx) { + const chatId = requireChatId(ctx); + if (!chatId) { + return [ + "ℹ️ No Feishu group context — running on the default workspace.", + `Default: \`${ctx.workspaceStore.resolve(null).cwd}\``, + ].join("\n"); + } + const resolution = ctx.workspaceStore.resolve(chatId); + const lines: string[] = []; + if (!resolution.binding) { + lines.push("ℹ️ This group is **not bound**."); + lines.push(`Default workspace: \`${resolution.cwd}\``); + lines.push("Use `/bind ` or `/clone ` to set up."); + return lines.join("\n"); + } + lines.push("**Group binding:**"); + lines.push(`- Workspace: \`${resolution.binding.workspace_path}\``); + lines.push(`- Active repo: \`${resolution.binding.active_repo ?? "(none)"}\``); + lines.push(`- Active branch: \`${resolution.binding.active_branch ?? "(none)"}\``); + const repos = listRepoBasenames(resolution.binding.workspace_path); + if (repos.length > 0) { + lines.push("", "**Cloned repos in workspace:**"); + for (const name of repos) lines.push(`- \`${name}\``); + } else { + lines.push("", "_Workspace has no cloned repos yet._"); + } + if (ctx.message.thread_id) { + lines.push("", `**This topic's session:** \`${ctx.message.session_id}\``); + } + return lines.join("\n"); + }, +}; + +const lsHandler: CommandHandler = { + name: "ls", + description: "/ls — list cloned repos in current group's workspace", + async execute(ctx) { + const chatId = requireChatId(ctx); + const resolution = ctx.workspaceStore.resolve(chatId ?? null); + const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; + const repos = listRepoBasenames(workspacePath); + if (repos.length === 0) { + return `_No repos under \`${workspacePath}\`. Use \`/clone \` to add one._`; + } + const primary = resolution.binding?.active_repo; + const lines = [`**Repos in \`${workspacePath}\`:**`]; + for (const name of repos) { + const mark = name === primary ? " ← active" : ""; + lines.push(`- \`${name}\`${mark}`); + } + return lines.join("\n"); + }, +}; + +const cloneHandler: CommandHandler = { + name: "clone", + description: "/clone [name] — clone a repo into current group's workspace", + async execute(ctx) { + const chatId = requireChatId(ctx); + if (!chatId) return "❌ /clone requires a Feishu group context."; + const [url, explicitName] = ctx.args; + if (!url) return "Usage: `/clone [name]`"; + const resolution = ctx.workspaceStore.resolve(chatId); + const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; + // Ensure binding row exists so workspace_path is persisted even before active_repo is picked + if (!resolution.binding) { + ctx.workspaceStore.upsertBinding(chatId, {}); + } + const name = explicitName || deriveRepoName(url); + const targetPath = join(workspacePath, name); + if (existsSync(targetPath)) { + return `❌ \`${name}\` already exists in workspace. Choose a different name or \`/ls\` to inspect.`; + } + ctx.logger.info({ chat_id: chatId, url, name }, "cloning repo"); + const result = await execGit(["clone", url, name], workspacePath); + if (!result.ok) { + return `❌ \`git clone\` failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; + } + return [ + `✅ Cloned \`${name}\` into workspace.`, + `Use \`/bind ${name} \` to make it the active repo for this group.`, + ].join("\n"); + }, +}; + +const checkoutHandler: CommandHandler = { + name: "checkout", + description: "/checkout — switch the active repo's branch", + async execute(ctx) { + const chatId = requireChatId(ctx); + if (!chatId) return "❌ /checkout requires a Feishu group context."; + const [branch] = ctx.args; + if (!branch) return "Usage: `/checkout `"; + const resolution = ctx.workspaceStore.resolve(chatId); + if (!resolution.binding?.active_repo) { + return "❌ No active repo. Run `/bind ` first."; + } + const repoPath = join( + resolution.binding.workspace_path, + resolution.binding.active_repo, + ); + // Reject dirty tree so the user can't accidentally drop uncommitted work + const status = await execGit(["status", "--short"], repoPath); + if (status.stdout) { + return `❌ Dirty tree in \`${resolution.binding.active_repo}\`, refusing to checkout.\n\`\`\`\n${status.stdout}\n\`\`\``; + } + const result = await execGit(["checkout", branch], repoPath); + if (!result.ok) { + return `❌ \`git checkout ${branch}\` failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; + } + ctx.workspaceStore.upsertBinding(chatId, { active_branch: branch }); + return `✅ \`${resolution.binding.active_repo}\` is now on branch \`${branch}\`.`; + }, +}; + +export const helpHandler: CommandHandler = { + name: "help", + description: "/help — show available commands", + async execute() { + return [ + "**Gateway commands (bypass LLM):**", + ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), + "- /help — this message", + "- /stop — cancel the running task in this session", + ].join("\n"); + }, +}; + +export const BUILTIN_COMMANDS: CommandHandler[] = [ + bindHandler, + unbindHandler, + statusHandler, + lsHandler, + cloneHandler, + checkoutHandler, +]; + +function listRepoBasenames(workspacePath: string): string[] { + if (!existsSync(workspacePath)) return []; + try { + return readdirSync(workspacePath) + .filter((name) => !name.startsWith(".")) + .filter((name) => { + const p = join(workspacePath, name); + try { + return statSync(p).isDirectory() && existsSync(join(p, ".git")); + } catch { + return false; + } + }) + .sort(); + } catch { + return []; + } +} + +function deriveRepoName(url: string): string { + let tail = url.trim(); + if (tail.endsWith("/")) tail = tail.slice(0, -1); + const last = basename(tail); + return last.endsWith(".git") ? last.slice(0, -4) : last; +} diff --git a/src/kernel/commands/index.ts b/src/kernel/commands/index.ts new file mode 100644 index 0000000..cfff7de --- /dev/null +++ b/src/kernel/commands/index.ts @@ -0,0 +1,4 @@ +export * from "./parser"; +export * from "./registry"; +export * from "./types"; +export { BUILTIN_COMMANDS, helpHandler } from "./handlers"; diff --git a/src/kernel/commands/parser.ts b/src/kernel/commands/parser.ts new file mode 100644 index 0000000..a258787 --- /dev/null +++ b/src/kernel/commands/parser.ts @@ -0,0 +1,23 @@ +import type { ParsedCommand } from "./types"; + +/** + * Parse a message body into a slash command. Returns null when the text + * doesn't start with `/` or is a single slash. Command name is normalized + * to lowercase; args are split on whitespace runs. + * + * Note: `/stop` is reserved for the kernel's task-cancel path and is + * handled before this parser is consulted. + */ +export function parseCommand(text: string): ParsedCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/") || trimmed.length < 2) return null; + const body = trimmed.slice(1); + const parts = body.split(/\s+/); + const name = parts[0]?.toLowerCase(); + if (!name) return null; + return { + name, + args: parts.slice(1), + raw: trimmed, + }; +} diff --git a/src/kernel/commands/registry.ts b/src/kernel/commands/registry.ts new file mode 100644 index 0000000..1352661 --- /dev/null +++ b/src/kernel/commands/registry.ts @@ -0,0 +1,26 @@ +import { BUILTIN_COMMANDS, helpHandler } from "./handlers"; +import type { CommandHandler } from "./types"; + + +/** + * In-memory registry of slash commands. Kernel looks up by `name.toLowerCase()` + * and dispatches. Single instance per kernel; no dynamic registration for v1. + */ +export class CommandRegistry { + private readonly _map = new Map(); + + constructor(handlers: CommandHandler[] = BUILTIN_COMMANDS) { + for (const handler of handlers) { + this._map.set(handler.name.toLowerCase(), handler); + } + this._map.set(helpHandler.name, helpHandler); + } + + get(name: string): CommandHandler | undefined { + return this._map.get(name.toLowerCase()); + } + + list(): CommandHandler[] { + return Array.from(this._map.values()); + } +} diff --git a/src/kernel/commands/types.ts b/src/kernel/commands/types.ts new file mode 100644 index 0000000..58eb9ba --- /dev/null +++ b/src/kernel/commands/types.ts @@ -0,0 +1,35 @@ +import type { Logger, UserMessage } from "@/shared"; + +import type { GroupWorkspaceStore } from "../workspaces"; + +/** + * Parsed slash command from an inbound message. + * `name` is lowercase; `args` is whitespace-split and preserves order. + */ +export interface ParsedCommand { + name: string; + args: string[]; + raw: string; +} + +/** Execution context passed to a command handler. */ +export interface CommandContext { + message: UserMessage; + args: string[]; + raw: string; + workspaceStore: GroupWorkspaceStore; + logger: Logger; +} + +/** A gateway-level command that bypasses the LLM entirely. */ +export interface CommandHandler { + /** Command name without the leading slash (lowercase). */ + readonly name: string; + /** One-line help text shown by `/help`. */ + readonly description: string; + /** Run the command; return reply text that will be sent to the chat. */ + execute( + // eslint-disable-next-line no-unused-vars + ctx: CommandContext, + ): Promise; +} diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 15b5a80..6326cf7 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -13,11 +13,13 @@ import { import { HonoServer } from "../server"; +import { CommandRegistry, parseCommand } from "./commands"; import { MultiChannelMessageGateway } from "./messaging"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; import { TaskDispatcher } from "./tasking"; import * as taskingSchema from "./tasking/data"; +import { GroupWorkspaceStore } from "./workspaces"; /** * The kernel is the main entry point for the agentara application. @@ -30,10 +32,14 @@ class Kernel { private _taskDispatcher!: TaskDispatcher; private _messageGateway!: MultiChannelMessageGateway; private _honoServer!: HonoServer; + private _workspaceStore!: GroupWorkspaceStore; + private _commandRegistry!: CommandRegistry; constructor() { this._initDatabase(); this._initSessionManager(); + this._initWorkspaceStore(); + this._initCommandRegistry(); this._initTaskDispatcher(); this._initMessageGateway(); this._initServer(); @@ -67,6 +73,15 @@ class Kernel { this._sessionManager = new SessionManager(this._database.db); } + private _initWorkspaceStore(): void { + this._workspaceStore = new GroupWorkspaceStore(this._database.db); + this._workspaceStore.ensureBaseDirs(); + } + + private _initCommandRegistry(): void { + this._commandRegistry = new CommandRegistry(); + } + private _initServer(): void { this._honoServer = new HonoServer(); } @@ -114,12 +129,18 @@ class Kernel { private _handleInboundMessage = async (message: UserMessage) => { const text = extractTextContent(message).trim(); - // Handle /stop command + // Handle /stop command (kernel-owned because it talks to TaskDispatcher) if (text === "/stop") { await this._handleStopCommand(message); return; } + // Try gateway-level slash commands before dispatching to the LLM. + if (text.startsWith("/")) { + const handled = await this._tryHandleCommand(message, text); + if (handled) return; + } + const task: InboundMessageTaskPayload = { type: "inbound_message", message, @@ -127,6 +148,38 @@ class Kernel { await this._taskDispatcher.dispatch(message.session_id, task); }; + private _tryHandleCommand = async ( + message: UserMessage, + text: string, + ): Promise => { + const parsed = parseCommand(text); + if (!parsed) return false; + const handler = this._commandRegistry.get(parsed.name); + if (!handler) return false; + let replyText: string; + try { + replyText = await handler.execute({ + message, + args: parsed.args, + raw: parsed.raw, + workspaceStore: this._workspaceStore, + logger: this._logger, + }); + } catch (err) { + this._logger.error( + { err, command: parsed.name, chat_id: message.chat_id }, + "command handler failed", + ); + replyText = `❌ Command \`/${parsed.name}\` failed: ${(err as Error).message}`; + } + await this._messageGateway.replyMessage(message.id, { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text: replyText }], + }); + return true; + }; + private _handleStopCommand = async (message: UserMessage) => { const sessionId = message.session_id; const runningTaskId = @@ -169,8 +222,31 @@ class Kernel { signal?: AbortSignal, ) => { const inboundMessage = payload.message; + const resolution = this._workspaceStore.resolve(inboundMessage.chat_id); + if (resolution.binding?.active_repo && resolution.binding.active_branch) { + // Idempotent pre-checkout so the active repo is on the bound branch + // before the runner spawns. Runs synchronously with the dispatch — if + // it fails, we still proceed (binding may need user repair). + try { + const repoPath = `${resolution.binding.workspace_path}/${resolution.binding.active_repo}`; + const proc = Bun.spawn( + ["git", "-C", repoPath, "checkout", resolution.binding.active_branch], + { stdout: "pipe", stderr: "pipe" }, + ); + await proc.exited; + } catch (err) { + this._logger.warn( + { err, chat_id: inboundMessage.chat_id, binding: resolution.binding }, + "pre-dispatch git checkout failed; continuing", + ); + } + } const session = await this._sessionManager.resolveSession(sessionId, { channelId: inboundMessage.channel_id, + chatId: inboundMessage.chat_id, + threadId: inboundMessage.thread_id, + cwd: resolution.cwd, + envExtras: resolution.envExtras, firstMessage: inboundMessage, }); let contents: AssistantMessage["content"] = [ diff --git a/src/kernel/sessioning/data/schema.ts b/src/kernel/sessioning/data/schema.ts index 00cf487..1a70e33 100644 --- a/src/kernel/sessioning/data/schema.ts +++ b/src/kernel/sessioning/data/schema.ts @@ -15,6 +15,10 @@ export const sessions = sqliteTable("sessions", { cwd: text("cwd").notNull(), /** The channel id this session belongs to, or null for legacy sessions. */ channel_id: text("channel_id"), + /** Feishu chat_id owning this session. Null for non-Feishu or legacy. */ + chat_id: text("chat_id"), + /** Feishu topic/thread id for this session. Null for non-threaded. */ + thread_id: text("thread_id"), /** The text content of the session's first inbound message. */ first_message: text("first_message").notNull().default(""), /** Runner-specific session/thread id (e.g. Codex thread id) for resume. */ @@ -26,3 +30,26 @@ export const sessions = sqliteTable("sessions", { /** Epoch milliseconds when the session was last updated. */ updated_at: integer("updated_at").notNull(), }); + +/** + * Persisted group↔workspace bindings. One row per Feishu group that has been + * bound via `/bind` or `/init`. Absence of a row means the group is unbound + * and falls back to the default workspace. + * + * workspace_path is always `$AGENTARA_HOME/workspaces//`; stored to + * make the value explicit and survive config path changes. + */ +export const groupWorkspaces = sqliteTable("group_workspaces", { + /** Feishu chat id. */ + chat_id: text("chat_id").primaryKey(), + /** Absolute path of the group's workspace directory. */ + workspace_path: text("workspace_path").notNull(), + /** Basename of the currently focused repo under workspace_path, or null. */ + active_repo: text("active_repo"), + /** Git branch to check out in the active repo on dispatch, or null. */ + active_branch: text("active_branch"), + /** Epoch milliseconds when the binding was created. */ + created_at: integer("created_at").notNull(), + /** Epoch milliseconds when the binding was last updated. */ + updated_at: integer("updated_at").notNull(), +}); diff --git a/src/kernel/sessioning/session-manager.ts b/src/kernel/sessioning/session-manager.ts index 174440a..2c90b50 100644 --- a/src/kernel/sessioning/session-manager.ts +++ b/src/kernel/sessioning/session-manager.ts @@ -36,6 +36,22 @@ export interface SessionResolveOptions { */ channelId?: string; + /** + * Feishu chat_id for this session. Stored on create, not used on resume. + */ + chatId?: string | null; + + /** + * Feishu thread/topic id for this session. Stored on create, not used on resume. + */ + threadId?: string | null; + + /** + * Extra env vars to pass into the runner spawn (e.g. DEV_ASSETS_PRIMARY_REPO). + * Not persisted; re-resolved on every dispatch from group binding. + */ + envExtras?: Record; + /** * The first message of the session. */ @@ -110,6 +126,9 @@ export class SessionManager { const agentType = options?.agentType ?? config.agents.default.type; const cwd = options?.cwd ?? config.paths.home; const channelId = options?.channelId ?? null; + const chatId = options?.chatId ?? null; + const threadId = options?.threadId ?? null; + const envExtras = options?.envExtras; const now = Date.now(); this._db @@ -119,6 +138,8 @@ export class SessionManager { agent_type: agentType, cwd, channel_id: channelId, + chat_id: chatId, + thread_id: threadId, last_message_created_at: null, runner_session_id: null, created_at: now, @@ -137,6 +158,7 @@ export class SessionManager { const session = new Session(sessionId, agentType, { isNewSession: true, cwd, + envExtras, runnerSessionId: undefined, }); this._attachWriter(session, sessionId); @@ -172,6 +194,7 @@ export class SessionManager { { isNewSession: false, cwd: options?.cwd ?? row.cwd, + envExtras: options?.envExtras, runnerSessionId: row.runner_session_id ?? undefined, }, ); diff --git a/src/kernel/workspaces/index.ts b/src/kernel/workspaces/index.ts new file mode 100644 index 0000000..f5990c2 --- /dev/null +++ b/src/kernel/workspaces/index.ts @@ -0,0 +1 @@ +export * from "./store"; diff --git a/src/kernel/workspaces/store.ts b/src/kernel/workspaces/store.ts new file mode 100644 index 0000000..e5fc456 --- /dev/null +++ b/src/kernel/workspaces/store.ts @@ -0,0 +1,173 @@ +import { existsSync, mkdirSync } from "node:fs"; + +import { eq } from "drizzle-orm"; + +import type { DrizzleDB } from "@/data"; +import { groupWorkspaces } from "@/kernel/sessioning/data"; +import { config, createLogger, type GroupWorkspace } from "@/shared"; + +/** + * Resolution result for a group's dispatch context: the cwd to spawn the + * runner in plus any env extras (primary repo hint) derived from the active + * binding. When `chatId` is unbound, callers fall back to the default + * workspace and empty envExtras. + */ +export interface WorkspaceResolution { + cwd: string; + envExtras: Record; + binding: GroupWorkspace | null; +} + +/** + * CRUD + resolution over the `group_workspaces` table. Single-writer; no + * caching. Callers should treat results as a snapshot for one dispatch. + */ +export class GroupWorkspaceStore { + private readonly _logger = createLogger("group-workspace-store"); + private readonly _db: DrizzleDB; + + constructor(db: DrizzleDB) { + this._db = db; + } + + /** + * Ensure the default workspace root + `_default` fallback both exist. + * Idempotent; safe to call on every boot. + */ + ensureBaseDirs(): void { + for (const dir of [config.paths.workspaces, config.paths.default_workspace]) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + this._logger.info(`Created workspace dir: ${dir}`); + } + } + } + + /** Fetch the binding for a Feishu chat, or null when none. */ + getBinding(chatId: string): GroupWorkspace | null { + const row = this._db + .select() + .from(groupWorkspaces) + .where(eq(groupWorkspaces.chat_id, chatId)) + .get(); + return row ?? null; + } + + /** List all bindings, most recently updated first. */ + listBindings(): GroupWorkspace[] { + return this._db.select().from(groupWorkspaces).all(); + } + + /** + * Upsert the binding for a chat. Creates the row if absent; otherwise + * merges non-undefined fields over the existing row. Ensures the + * workspace directory exists on disk. + */ + upsertBinding( + chatId: string, + patch: { active_repo?: string | null; active_branch?: string | null }, + ): GroupWorkspace { + const workspacePath = config.paths.resolveGroupWorkspacePath(chatId); + if (!existsSync(workspacePath)) { + mkdirSync(workspacePath, { recursive: true }); + this._logger.info(`Created group workspace: ${workspacePath}`); + } + + const now = Date.now(); + const existing = this.getBinding(chatId); + if (!existing) { + const row: GroupWorkspace = { + chat_id: chatId, + workspace_path: workspacePath, + active_repo: patch.active_repo ?? null, + active_branch: patch.active_branch ?? null, + created_at: now, + updated_at: now, + }; + this._db.insert(groupWorkspaces).values(row).run(); + return row; + } + + const merged: GroupWorkspace = { + ...existing, + workspace_path: workspacePath, + active_repo: + patch.active_repo === undefined ? existing.active_repo : patch.active_repo, + active_branch: + patch.active_branch === undefined + ? existing.active_branch + : patch.active_branch, + updated_at: now, + }; + this._db + .update(groupWorkspaces) + .set({ + workspace_path: merged.workspace_path, + active_repo: merged.active_repo, + active_branch: merged.active_branch, + updated_at: merged.updated_at, + }) + .where(eq(groupWorkspaces.chat_id, chatId)) + .run(); + return merged; + } + + /** Remove the binding for a chat. No-op when absent. */ + deleteBinding(chatId: string): boolean { + const existing = this.getBinding(chatId); + if (!existing) return false; + this._db + .delete(groupWorkspaces) + .where(eq(groupWorkspaces.chat_id, chatId)) + .run(); + return true; + } + + /** + * Resolve the cwd + envExtras pair a dispatch should use for this chat. + * + * - Binding present with active_repo → cwd = workspace_path, + * DEV_ASSETS_PRIMARY_REPO/BRANCH in envExtras + * - Binding present without active_repo → cwd = workspace_path, no env hint + * - No binding (or no chatId) → cwd = default workspace, no env hint + * + * Guarantees cwd exists on disk. + */ + resolve(chatId: string | null | undefined): WorkspaceResolution { + if (!chatId) { + return this._defaultResolution(null); + } + const binding = this.getBinding(chatId); + if (!binding) { + return this._defaultResolution(null); + } + if (!existsSync(binding.workspace_path)) { + mkdirSync(binding.workspace_path, { recursive: true }); + } + const envExtras: Record = {}; + if (binding.active_repo) { + envExtras.DEV_ASSETS_PRIMARY_REPO = binding.active_repo; + if (binding.active_branch) { + envExtras.DEV_ASSETS_PRIMARY_BRANCH = binding.active_branch; + } + } + return { + cwd: binding.workspace_path, + envExtras, + binding, + }; + } + + private _defaultResolution( + binding: GroupWorkspace | null, + ): WorkspaceResolution { + if (!existsSync(config.paths.default_workspace)) { + mkdirSync(config.paths.default_workspace, { recursive: true }); + } + return { + cwd: config.paths.default_workspace, + envExtras: {}, + binding, + }; + } +} diff --git a/src/shared/agents/agent-runner.ts b/src/shared/agents/agent-runner.ts index fafe1ca..b245a54 100644 --- a/src/shared/agents/agent-runner.ts +++ b/src/shared/agents/agent-runner.ts @@ -26,6 +26,13 @@ export const AgentRunOptions = z.object({ */ runnerSessionId: z.string().optional(), + /** + * Extra environment variables merged into the runner's spawn env. Used to + * thread per-group hints (e.g. `DEV_ASSETS_PRIMARY_REPO`) into Claude/Codex + * CLI invocations without touching the caller's process env. + */ + envExtras: z.record(z.string(), z.string()).optional(), + /** * Abort signal for cancelling the running task. * When aborted, the agent runner should kill any spawned subprocesses. diff --git a/src/shared/config/paths.ts b/src/shared/config/paths.ts index 84d9f86..c07615b 100644 --- a/src/shared/config/paths.ts +++ b/src/shared/config/paths.ts @@ -23,6 +23,16 @@ export const projects = join(workspace, "projects"); export const uploads = join(workspace, "uploads"); export const outputs = join(workspace, "outputs"); +/** + * Per-group workspace root container: `$AGENTARA_HOME/workspaces//`. + * `_default/` inside it is the fallback workspace for unbound groups. + */ +export const workspaces = join(home, "workspaces"); +export const default_workspace = join(workspaces, "_default"); +export function resolveGroupWorkspacePath(chat_id: string) { + return join(workspaces, chat_id); +} + export const data = join(home, "data"); export function resolveDataFilePath(filename: string) { return join(data, filename); diff --git a/src/shared/index.ts b/src/shared/index.ts index 81f0b9d..8779824 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -7,3 +7,4 @@ export * from "./sessioning"; export * from "./skills"; export * from "./tasking"; export * from "./utils"; +export * from "./workspaces"; diff --git a/src/shared/messaging/types/message.ts b/src/shared/messaging/types/message.ts index 34ed596..62d6a84 100644 --- a/src/shared/messaging/types/message.ts +++ b/src/shared/messaging/types/message.ts @@ -46,6 +46,10 @@ export const UserMessage = BaseMessage.extend({ role: z.literal("user"), /** The channel id this message originated from. */ channel_id: z.string().optional(), + /** Feishu chat_id, when this message originated from a Feishu channel. */ + chat_id: z.string().optional(), + /** Feishu topic/thread id, when the message is inside a topic. */ + thread_id: z.string().optional(), content: z.array( z.discriminatedUnion("type", [ TextMessageContent, diff --git a/src/shared/sessioning/types/session.ts b/src/shared/sessioning/types/session.ts index bceefb4..caebb9b 100644 --- a/src/shared/sessioning/types/session.ts +++ b/src/shared/sessioning/types/session.ts @@ -15,6 +15,10 @@ export const Session = z.object({ cwd: z.string(), /** The channel id this session belongs to, or null/undefined for legacy sessions. */ channel_id: z.string().optional().nullable(), + /** Feishu chat_id owning this session; null for non-Feishu sessions. */ + chat_id: z.string().optional().nullable(), + /** Feishu topic/thread id for this session; null for non-threaded sessions. */ + thread_id: z.string().optional().nullable(), /** The text content of the session's first inbound message. */ first_message: z.string(), /** Runner-specific session/thread id (e.g. Codex thread id), if available. */ diff --git a/src/shared/workspaces/index.ts b/src/shared/workspaces/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/src/shared/workspaces/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/src/shared/workspaces/types/group-workspace.ts b/src/shared/workspaces/types/group-workspace.ts new file mode 100644 index 0000000..bca9718 --- /dev/null +++ b/src/shared/workspaces/types/group-workspace.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/** + * A persisted per-group workspace binding. + * + * Each Feishu group that has been bound via `/bind` or `/init` owns one row. + * The workspace directory is always `$AGENTARA_HOME/workspaces//` + * and may host multiple cloned repos as first-level subdirectories. The + * active `(repo, branch)` pointer is what `_handleInboundMessageTask` reads + * to decide cwd and `DEV_ASSETS_PRIMARY_REPO` env extras. + */ +export const GroupWorkspace = z.object({ + /** Feishu chat id. */ + chat_id: z.string(), + /** Absolute path of the workspace root. */ + workspace_path: z.string(), + /** Basename of the active repo under the workspace root, or null. */ + active_repo: z.string().nullable(), + /** Git branch the active repo should be on at dispatch, or null. */ + active_branch: z.string().nullable(), + /** Epoch ms when the binding was created. */ + created_at: z.number(), + /** Epoch ms when the binding was last updated. */ + updated_at: z.number(), +}); +export interface GroupWorkspace extends z.infer {} diff --git a/src/shared/workspaces/types/index.ts b/src/shared/workspaces/types/index.ts new file mode 100644 index 0000000..a968386 --- /dev/null +++ b/src/shared/workspaces/types/index.ts @@ -0,0 +1 @@ +export * from "./group-workspace"; From 5ae1650acb517e4b88bc55f8c4e6135815b75bdb Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 15:03:21 +0800 Subject: [PATCH 02/69] fix(commands): strip Feishu @mentions; Chinese replies Two issues in the gateway slash-command path: 1. Feishu substitutes @mentions in the text body as `@_user_N` placeholders. `@bot /bind foo main` arrived as `@_user_1 /bind foo main`, which failed the `startsWith("/")` check and fell through to the LLM. Strip the placeholder before command routing. 2. All user-facing reply copy was English; Feishu group audience is Chinese-speaking. Translate command descriptions and all success / error / usage messages in handlers.ts, plus the /stop replies and the command-handler error wrapper in kernel.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kernel/commands/handlers.ts | 97 +++++++++++++++++---------------- src/kernel/kernel.ts | 12 ++-- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 793858b..28736c9 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -8,6 +8,9 @@ import type { CommandContext, CommandHandler } from "./types"; * TaskDispatcher. All commands are scoped to the current Feishu `chat_id` * unless noted. Missing `chat_id` means the message came from a source * that doesn't support group bindings; we reject those commands politely. + * + * User-facing copy is intentionally in Chinese — these commands surface + * directly in Feishu group chats where the audience is Chinese-speaking. */ function requireChatId(ctx: CommandContext): string | null { @@ -37,23 +40,23 @@ async function execGit( const bindHandler: CommandHandler = { name: "bind", - description: "/bind — bind current group to a repo + branch", + description: "/bind <仓库> <分支> — 绑定当前群到指定仓库和分支", async execute(ctx) { const chatId = requireChatId(ctx); - if (!chatId) return "❌ /bind requires a Feishu group context."; + if (!chatId) return "❌ /bind 仅在飞书群内可用。"; const [repo, branch] = ctx.args; if (!repo || !branch) { - return "Usage: `/bind `\nHint: clone first via `/clone `."; + return "用法:`/bind <仓库目录名> <分支>`\n提示:若还未克隆,先用 `/clone `。"; } const resolution = ctx.workspaceStore.resolve(chatId); const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; const repoPath = join(workspacePath, repo); if (!existsSync(repoPath) || !existsSync(join(repoPath, ".git"))) { - return `❌ \`${repo}\` not found under workspace \`${workspacePath}\`. Run \`/clone \` first, or check \`/ls\`.`; + return `❌ 在 workspace 中找不到 \`${repo}\`(\`${workspacePath}\`)。请先 \`/clone \`,或用 \`/ls\` 查看已克隆的仓库。`; } const checkout = await execGit(["checkout", branch], repoPath); if (!checkout.ok) { - return `❌ \`git checkout ${branch}\` failed:\n\`\`\`\n${checkout.stderr || checkout.stdout}\n\`\`\``; + return `❌ \`git checkout ${branch}\` 失败:\n\`\`\`\n${checkout.stderr || checkout.stdout}\n\`\`\``; } const binding = ctx.workspaceStore.upsertBinding(chatId, { active_repo: repo, @@ -61,57 +64,57 @@ const bindHandler: CommandHandler = { }); ctx.logger.info({ chat_id: chatId, binding }, "group binding updated"); return [ - `✅ Bound group to \`${repo}\` @ \`${branch}\``, - `Workspace: \`${binding.workspace_path}\``, - `Next messages will use this repo+branch until \`/unbind\` or another \`/bind\`.`, + `✅ 已将当前群绑定到 \`${repo}\` @ \`${branch}\``, + `Workspace:\`${binding.workspace_path}\``, + `后续消息将使用该仓库和分支,直到再次 \`/bind\` 或 \`/unbind\`。`, ].join("\n"); }, }; const unbindHandler: CommandHandler = { name: "unbind", - description: "/unbind — clear this group's binding (fall back to default workspace)", + description: "/unbind — 清除当前群的绑定(回退到默认 workspace)", async execute(ctx) { const chatId = requireChatId(ctx); - if (!chatId) return "❌ /unbind requires a Feishu group context."; + if (!chatId) return "❌ /unbind 仅在飞书群内可用。"; const removed = ctx.workspaceStore.deleteBinding(chatId); - if (!removed) return "ℹ️ This group is not bound."; - return "✅ Group binding cleared. Future messages will use the default workspace."; + if (!removed) return "ℹ️ 当前群未绑定。"; + return "✅ 群绑定已清除。后续消息将使用默认 workspace。"; }, }; const statusHandler: CommandHandler = { name: "status", - description: "/status — show current group binding + cloned repos", + description: "/status — 查看当前群的绑定及已克隆的仓库", async execute(ctx) { const chatId = requireChatId(ctx); if (!chatId) { return [ - "ℹ️ No Feishu group context — running on the default workspace.", - `Default: \`${ctx.workspaceStore.resolve(null).cwd}\``, + "ℹ️ 当前不在飞书群上下文,正在使用默认 workspace。", + `默认路径:\`${ctx.workspaceStore.resolve(null).cwd}\``, ].join("\n"); } const resolution = ctx.workspaceStore.resolve(chatId); const lines: string[] = []; if (!resolution.binding) { - lines.push("ℹ️ This group is **not bound**."); - lines.push(`Default workspace: \`${resolution.cwd}\``); - lines.push("Use `/bind ` or `/clone ` to set up."); + lines.push("ℹ️ 当前群 **未绑定**。"); + lines.push(`默认 workspace:\`${resolution.cwd}\``); + lines.push("使用 `/bind <仓库> <分支>` 或 `/clone ` 来初始化。"); return lines.join("\n"); } - lines.push("**Group binding:**"); - lines.push(`- Workspace: \`${resolution.binding.workspace_path}\``); - lines.push(`- Active repo: \`${resolution.binding.active_repo ?? "(none)"}\``); - lines.push(`- Active branch: \`${resolution.binding.active_branch ?? "(none)"}\``); + lines.push("**当前群绑定:**"); + lines.push(`- Workspace:\`${resolution.binding.workspace_path}\``); + lines.push(`- 活跃仓库:\`${resolution.binding.active_repo ?? "(未设置)"}\``); + lines.push(`- 活跃分支:\`${resolution.binding.active_branch ?? "(未设置)"}\``); const repos = listRepoBasenames(resolution.binding.workspace_path); if (repos.length > 0) { - lines.push("", "**Cloned repos in workspace:**"); + lines.push("", "**Workspace 中已克隆的仓库:**"); for (const name of repos) lines.push(`- \`${name}\``); } else { - lines.push("", "_Workspace has no cloned repos yet._"); + lines.push("", "_Workspace 还没有克隆任何仓库。_"); } if (ctx.message.thread_id) { - lines.push("", `**This topic's session:** \`${ctx.message.session_id}\``); + lines.push("", `**当前话题的 session:** \`${ctx.message.session_id}\``); } return lines.join("\n"); }, @@ -119,19 +122,19 @@ const statusHandler: CommandHandler = { const lsHandler: CommandHandler = { name: "ls", - description: "/ls — list cloned repos in current group's workspace", + description: "/ls — 列出当前群 workspace 下的所有仓库", async execute(ctx) { const chatId = requireChatId(ctx); const resolution = ctx.workspaceStore.resolve(chatId ?? null); const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; const repos = listRepoBasenames(workspacePath); if (repos.length === 0) { - return `_No repos under \`${workspacePath}\`. Use \`/clone \` to add one._`; + return `_\`${workspacePath}\` 下还没有仓库。使用 \`/clone \` 添加一个。_`; } const primary = resolution.binding?.active_repo; - const lines = [`**Repos in \`${workspacePath}\`:**`]; + const lines = [`**\`${workspacePath}\` 下的仓库:**`]; for (const name of repos) { - const mark = name === primary ? " ← active" : ""; + const mark = name === primary ? " ← 活跃" : ""; lines.push(`- \`${name}\`${mark}`); } return lines.join("\n"); @@ -140,12 +143,12 @@ const lsHandler: CommandHandler = { const cloneHandler: CommandHandler = { name: "clone", - description: "/clone [name] — clone a repo into current group's workspace", + description: "/clone [别名] — 将仓库克隆到当前群的 workspace", async execute(ctx) { const chatId = requireChatId(ctx); - if (!chatId) return "❌ /clone requires a Feishu group context."; + if (!chatId) return "❌ /clone 仅在飞书群内可用。"; const [url, explicitName] = ctx.args; - if (!url) return "Usage: `/clone [name]`"; + if (!url) return "用法:`/clone [别名]`"; const resolution = ctx.workspaceStore.resolve(chatId); const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; // Ensure binding row exists so workspace_path is persisted even before active_repo is picked @@ -155,31 +158,31 @@ const cloneHandler: CommandHandler = { const name = explicitName || deriveRepoName(url); const targetPath = join(workspacePath, name); if (existsSync(targetPath)) { - return `❌ \`${name}\` already exists in workspace. Choose a different name or \`/ls\` to inspect.`; + return `❌ workspace 中已存在 \`${name}\`,请换个别名,或使用 \`/ls\` 查看已克隆的仓库。`; } ctx.logger.info({ chat_id: chatId, url, name }, "cloning repo"); const result = await execGit(["clone", url, name], workspacePath); if (!result.ok) { - return `❌ \`git clone\` failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; + return `❌ \`git clone\` 失败:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; } return [ - `✅ Cloned \`${name}\` into workspace.`, - `Use \`/bind ${name} \` to make it the active repo for this group.`, + `✅ 已克隆 \`${name}\` 到 workspace。`, + `使用 \`/bind ${name} <分支>\` 将其设为当前群的活跃仓库。`, ].join("\n"); }, }; const checkoutHandler: CommandHandler = { name: "checkout", - description: "/checkout — switch the active repo's branch", + description: "/checkout <分支> — 切换当前活跃仓库的分支", async execute(ctx) { const chatId = requireChatId(ctx); - if (!chatId) return "❌ /checkout requires a Feishu group context."; + if (!chatId) return "❌ /checkout 仅在飞书群内可用。"; const [branch] = ctx.args; - if (!branch) return "Usage: `/checkout `"; + if (!branch) return "用法:`/checkout <分支>`"; const resolution = ctx.workspaceStore.resolve(chatId); if (!resolution.binding?.active_repo) { - return "❌ No active repo. Run `/bind ` first."; + return "❌ 当前群没有活跃仓库。请先执行 `/bind <仓库> <分支>`。"; } const repoPath = join( resolution.binding.workspace_path, @@ -188,26 +191,26 @@ const checkoutHandler: CommandHandler = { // Reject dirty tree so the user can't accidentally drop uncommitted work const status = await execGit(["status", "--short"], repoPath); if (status.stdout) { - return `❌ Dirty tree in \`${resolution.binding.active_repo}\`, refusing to checkout.\n\`\`\`\n${status.stdout}\n\`\`\``; + return `❌ \`${resolution.binding.active_repo}\` 工作区有未提交改动,拒绝切换分支:\n\`\`\`\n${status.stdout}\n\`\`\``; } const result = await execGit(["checkout", branch], repoPath); if (!result.ok) { - return `❌ \`git checkout ${branch}\` failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; + return `❌ \`git checkout ${branch}\` 失败:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; } ctx.workspaceStore.upsertBinding(chatId, { active_branch: branch }); - return `✅ \`${resolution.binding.active_repo}\` is now on branch \`${branch}\`.`; + return `✅ \`${resolution.binding.active_repo}\` 已切换到分支 \`${branch}\`。`; }, }; export const helpHandler: CommandHandler = { name: "help", - description: "/help — show available commands", + description: "/help — 显示所有可用命令", async execute() { return [ - "**Gateway commands (bypass LLM):**", + "**可用命令(不经大模型直接执行):**", ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), - "- /help — this message", - "- /stop — cancel the running task in this session", + "- /help — 显示本消息", + "- /stop — 取消当前 session 正在执行的任务", ].join("\n"); }, }; diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 6326cf7..4589456 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -127,7 +127,11 @@ class Kernel { } private _handleInboundMessage = async (message: UserMessage) => { - const text = extractTextContent(message).trim(); + // Feishu substitutes @mentions as `@_user_N` placeholders in the text body; + // strip them so `@bot /bind foo bar` routes through the slash command path. + const text = extractTextContent(message) + .replace(/@_user_\d+/g, "") + .trim(); // Handle /stop command (kernel-owned because it talks to TaskDispatcher) if (text === "/stop") { @@ -170,7 +174,7 @@ class Kernel { { err, command: parsed.name, chat_id: message.chat_id }, "command handler failed", ); - replyText = `❌ Command \`/${parsed.name}\` failed: ${(err as Error).message}`; + replyText = `❌ 命令 \`/${parsed.name}\` 执行失败:${(err as Error).message}`; } await this._messageGateway.replyMessage(message.id, { role: "assistant", @@ -190,13 +194,13 @@ class Kernel { await this._messageGateway.replyMessage(message.id, { role: "assistant", session_id: sessionId, - content: [{ type: "text", text: "Task stopped." }], + content: [{ type: "text", text: "✅ 任务已取消。" }], }); } else { await this._messageGateway.replyMessage(message.id, { role: "assistant", session_id: sessionId, - content: [{ type: "text", text: "No running task found." }], + content: [{ type: "text", text: "ℹ️ 当前 session 没有正在执行的任务。" }], }); } }; From b09f5e9c5e228cb07b136b82a51012620f9e660d Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 15:37:57 +0800 Subject: [PATCH 03/69] fix(commands): render gateway command replies correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway-level slash commands (/help, /stop, /bind, ...) were failing in two ways: 1. `MultiChannelMessageGateway.replyMessage` resolves the target channel by looking up `channel_id` on the session row, but gateway commands reply before any Session is persisted — the lookup returned null and threw "Cannot resolve channel for session". 2. `FeishuMessageChannel.replyMessage` defaults `streaming: true`, and the card renderer intentionally skips text rendering in streaming mode (only shows "生成中..." dots). Command replies are ready in one shot, so they never displayed. Fix both: add an optional `channelId` to the gateway's post/reply/update options that bypasses the session→channel DB lookup, and pass `channelId` + `streaming: false` from `_tryHandleCommand` and `_handleStopCommand`. --- src/kernel/kernel.ts | 42 +++++++++++------- .../multi-channel-message-gateway.ts | 43 ++++++++++++------- src/shared/messaging/message-gateway.ts | 21 ++++++--- 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 4589456..0d4bad9 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -176,11 +176,15 @@ class Kernel { ); replyText = `❌ 命令 \`/${parsed.name}\` 执行失败:${(err as Error).message}`; } - await this._messageGateway.replyMessage(message.id, { - role: "assistant", - session_id: message.session_id, - content: [{ type: "text", text: replyText }], - }); + await this._messageGateway.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text: replyText }], + }, + { channelId: message.channel_id, streaming: false }, + ); return true; }; @@ -191,17 +195,25 @@ class Kernel { if (runningTaskId) { await this._taskDispatcher.deleteTask(runningTaskId); - await this._messageGateway.replyMessage(message.id, { - role: "assistant", - session_id: sessionId, - content: [{ type: "text", text: "✅ 任务已取消。" }], - }); + await this._messageGateway.replyMessage( + message.id, + { + role: "assistant", + session_id: sessionId, + content: [{ type: "text", text: "✅ 任务已取消。" }], + }, + { channelId: message.channel_id, streaming: false }, + ); } else { - await this._messageGateway.replyMessage(message.id, { - role: "assistant", - session_id: sessionId, - content: [{ type: "text", text: "ℹ️ 当前 session 没有正在执行的任务。" }], - }); + await this._messageGateway.replyMessage( + message.id, + { + role: "assistant", + session_id: sessionId, + content: [{ type: "text", text: "ℹ️ 当前 session 没有正在执行的任务。" }], + }, + { channelId: message.channel_id, streaming: false }, + ); } }; diff --git a/src/kernel/messaging/multi-channel-message-gateway.ts b/src/kernel/messaging/multi-channel-message-gateway.ts index 1f82c70..7f13640 100644 --- a/src/kernel/messaging/multi-channel-message-gateway.ts +++ b/src/kernel/messaging/multi-channel-message-gateway.ts @@ -63,47 +63,53 @@ export class MultiChannelMessageGateway /** * Post a new assistant message without replying to an existing message. - * Routes to the correct channel based on session `channel_id`. + * Routes by the explicit `options.channelId` when provided, otherwise by the + * session's persisted `channel_id`. * @param message - The assistant message to post (without id). + * @param options - Optional settings. * @returns The posted message with id assigned. */ async postMessage( message: Omit, + options?: { channelId?: string }, ): Promise { - const channel = this._resolveChannelForSession(message.session_id); + const channel = this._resolveChannelFor(message.session_id, options?.channelId); const result = await channel.postMessage(message); return result; } /** * Reply to an existing message. - * Routes to the correct channel based on session `channel_id`. + * Routes by the explicit `options.channelId` when provided, otherwise by the + * session's persisted `channel_id`. Pass `channelId` for gateway-level replies + * that fire before any Session is created. * @param messageId - ID of the message to reply to. * @param message - The assistant message to send (without id). - * @param options - Optional settings (e.g. streaming mode). + * @param options - Optional settings. * @returns The sent message with id assigned. */ async replyMessage( messageId: string, message: Omit, - options?: { streaming?: boolean }, + options?: { streaming?: boolean; channelId?: string }, ): Promise { - const channel = this._resolveChannelForSession(message.session_id); + const channel = this._resolveChannelFor(message.session_id, options?.channelId); const result = await channel.replyMessage(messageId, message, options); return result; } /** * Update the content of an existing message. - * Routes to the correct channel based on session `channel_id`. + * Routes by the explicit `options.channelId` when provided, otherwise by the + * session's persisted `channel_id`. * @param message - The assistant message with updated content. - * @param options - Optional settings (e.g. streaming mode). + * @param options - Optional settings. */ async updateMessageContent( message: AssistantMessage, - options?: { streaming?: boolean }, + options?: { streaming?: boolean; channelId?: string }, ): Promise { - const channel = this._resolveChannelForSession(message.session_id); + const channel = this._resolveChannelFor(message.session_id, options?.channelId); await channel.updateMessageContent(message, options); } @@ -120,13 +126,18 @@ export class MultiChannelMessageGateway } /** - * Resolves the correct channel for a session by querying the `channel_id` - * from the sessions table. - * @param sessionId - The session identifier. - * @returns The matching MessageChannel. - * @throws If the session has no channel_id or the channel is not registered. + * Resolves the target channel. If `explicitChannelId` is provided, it wins + * (used for gateway-level replies before any Session is persisted). Otherwise + * falls back to querying `channel_id` from the sessions table. + * @throws If neither path yields a registered channel. */ - private _resolveChannelForSession(sessionId: string): MessageChannel { + private _resolveChannelFor( + sessionId: string, + explicitChannelId?: string, + ): MessageChannel { + if (explicitChannelId) { + return this._resolveChannel(explicitChannelId); + } const row = this._db .select({ channel_id: sessions.channel_id }) .from(sessions) diff --git a/src/shared/messaging/message-gateway.ts b/src/shared/messaging/message-gateway.ts index 5e1c7be..075330b 100644 --- a/src/shared/messaging/message-gateway.ts +++ b/src/shared/messaging/message-gateway.ts @@ -26,16 +26,24 @@ export interface MessageGateway extends EventEmitter { /** * Post a new assistant message without replying to an existing message. * @param message - The assistant message to post (without id). + * @param options - Optional settings. `channelId` bypasses the session→channel + * DB lookup; use it when the session row may not exist yet (e.g. gateway + * commands that reply before any Session is created). * @returns The posted message with id assigned. */ - // eslint-disable-next-line no-unused-vars - postMessage(message: Omit): Promise; + postMessage( + // eslint-disable-next-line no-unused-vars + message: Omit, + // eslint-disable-next-line no-unused-vars + options?: { channelId?: string }, + ): Promise; /** * Reply to an existing message. * @param messageId - ID of the message to reply to. * @param message - The assistant message to send (without id). - * @param options - Optional settings (e.g. streaming mode). + * @param options - Optional settings. `channelId` bypasses the session→channel + * DB lookup; use it when the session row may not exist yet. * @returns The sent message with id assigned. */ replyMessage( @@ -44,18 +52,19 @@ export interface MessageGateway extends EventEmitter { // eslint-disable-next-line no-unused-vars message: Omit, // eslint-disable-next-line no-unused-vars - options?: { streaming?: boolean }, + options?: { streaming?: boolean; channelId?: string }, ): Promise; /** * Update the content of an existing message. * @param message - The assistant message with updated content. - * @param options - Optional settings (e.g. streaming mode). + * @param options - Optional settings. `channelId` bypasses the session→channel + * DB lookup. */ updateMessageContent( // eslint-disable-next-line no-unused-vars message: AssistantMessage, // eslint-disable-next-line no-unused-vars - options?: { streaming?: boolean }, + options?: { streaming?: boolean; channelId?: string }, ): Promise; } From a98a7f32f971d625c2e07b2ff8ecd74d9ee6e3e0 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 15:38:26 +0800 Subject: [PATCH 04/69] feat(feishu): add inbound whitelist and @mention filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict which inbound messages the bot acts on, so one bot can live in multiple group chats without reacting to every member. Two independent filters, combined with AND: - `require_mention: true` — in group chats, require the bot to be @mentioned; P2P DMs bypass (already directed). Bot's own open_id is resolved at startup via `/bot/v3/info`. - `allowed_user_ids` / `allowed_user_emails` — sender's open_id must appear in the union of both sets. Emails are resolved to open_ids at startup via `/contact/v3/users/batch_get_id` (batched 50/req). Unresolved emails are logged, not fatal. Both filters fail loud at startup when the required token or lookup fails, so we never silently accept everything when the operator asked for restrictions. `ChannelParams` schema is widened to accept bool/number/array literals (normalized to strings via transform), so config.yaml stays natural: params: require_mention: true allowed_user_emails: [alice@x.com, bob@x.com] Also logs every inbound at info level (sender, chat_type, mentioned, passed) plus a dedicated line on each drop, since the channel was previously silent and operators could not tell why nothing fired. --- .../feishu/messaging/message-channel.ts | 208 +++++++++++++++++- src/kernel/kernel.ts | 14 ++ src/shared/config/schema.ts | 21 +- 3 files changed, 237 insertions(+), 6 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index e321146..ba1d666 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -58,10 +58,20 @@ export class FeishuMessageChannel private _db: DrizzleDB; private _failedCardUpdateMessages = new Set(); private _logger: Logger; + private _requireMention: boolean; + private _botOpenId?: string; + private _allowedUserOpenIds?: Set; + private _allowedUserEmails?: string[]; /** * Create a Feishu message channel. - * @param config - Feishu app credentials (defaults to env vars). + * @param config - Feishu app credentials, plus optional inbound filters: + * - `requireMention`: when true, group-chat messages must @mention the bot. + * The bot's own open_id is resolved at `start()` via `/bot/v3/info`. P2P + * messages bypass the check — they are obviously directed at the bot. + * - `allowedUserOpenIds` / `allowedUserEmails`: when either is non-empty, + * the sender's open_id must be in the union of the two sets. Emails are + * resolved to open_ids at `start()` via `/contact/v3/users/batch_get_id`. * @param db - Drizzle database instance for persisting thread-to-session mappings. */ constructor( @@ -70,6 +80,9 @@ export class FeishuMessageChannel chatId: string; appId: string; appSecret: string; + requireMention?: boolean; + allowedUserOpenIds?: string[]; + allowedUserEmails?: string[]; }, db: DrizzleDB, ) { @@ -80,6 +93,13 @@ export class FeishuMessageChannel } this._db = db; this._logger = createLogger("feishu-message-channel"); + this._requireMention = !!config.requireMention; + if (config.allowedUserOpenIds && config.allowedUserOpenIds.length > 0) { + this._allowedUserOpenIds = new Set(config.allowedUserOpenIds); + } + if (config.allowedUserEmails && config.allowedUserEmails.length > 0) { + this._allowedUserEmails = config.allowedUserEmails; + } this._inboundClient = new WSClient({ appId: this.config.appId, appSecret: this.config.appSecret, @@ -92,6 +112,47 @@ export class FeishuMessageChannel /** Start listening for inbound messages via WebSocket. */ async start() { + const needsToken = this._requireMention || !!this._allowedUserEmails; + const tenantToken = needsToken ? await this._fetchTenantAccessToken() : null; + + if (this._requireMention && tenantToken) { + this._botOpenId = await this._fetchBotOpenId(tenantToken); + this._logger.info( + { bot_open_id: this._botOpenId }, + "resolved bot open_id for @mention filtering", + ); + } + + if (this._allowedUserEmails && tenantToken) { + const resolved = await this._resolveEmailsToOpenIds( + this._allowedUserEmails, + tenantToken, + ); + if (!this._allowedUserOpenIds) { + this._allowedUserOpenIds = new Set(); + } + for (const openId of resolved.values()) { + this._allowedUserOpenIds.add(openId); + } + const unresolved = this._allowedUserEmails.filter( + (e) => !resolved.has(e), + ); + this._logger.info( + { + resolved_count: resolved.size, + unresolved, + total_whitelist: this._allowedUserOpenIds.size, + }, + "resolved email whitelist to open_ids", + ); + if (unresolved.length > 0) { + this._logger.warn( + { unresolved }, + "some whitelisted emails could not be resolved to an open_id", + ); + } + } + await this._inboundClient.start({ eventDispatcher: new EventDispatcher({}).register({ "im.message.receive_v1": this._handleMessageReceive, @@ -100,6 +161,101 @@ export class FeishuMessageChannel }); } + /** + * Exchange app credentials for a tenant access token. Used for REST calls + * that the node-sdk doesn't expose directly (bot info, email→id lookup). + */ + private async _fetchTenantAccessToken(): Promise { + const res = await fetch( + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + app_id: this.config.appId, + app_secret: this.config.appSecret, + }), + }, + ); + const json = (await res.json()) as { + code: number; + msg: string; + tenant_access_token?: string; + }; + if (json.code !== 0 || !json.tenant_access_token) { + throw new Error( + `Failed to obtain tenant_access_token: ${json.code} ${json.msg}`, + ); + } + return json.tenant_access_token; + } + + /** + * Fetch the bot's own open_id via `/bot/v3/info`. Requires the bot app to + * have "Get bot info" permission. Throws on failure — we'd rather fail loud + * than silently accept every message when the user asked for mention-only. + */ + private async _fetchBotOpenId(tenantToken: string): Promise { + const res = await fetch("https://open.feishu.cn/open-apis/bot/v3/info", { + method: "GET", + headers: { Authorization: `Bearer ${tenantToken}` }, + }); + const json = (await res.json()) as { + code: number; + msg: string; + bot?: { open_id?: string }; + }; + if (json.code !== 0 || !json.bot?.open_id) { + throw new Error(`Failed to fetch bot info: ${json.code} ${json.msg}`); + } + return json.bot.open_id; + } + + /** + * Resolve emails to open_ids via `/contact/v3/users/batch_get_id`. Requires + * the bot app to have "Get user ID by mobile/email" permission. Emails that + * don't map to a user are omitted from the returned map (the caller logs + * unresolved entries). Batches of 50 per the API limit. + */ + private async _resolveEmailsToOpenIds( + emails: string[], + tenantToken: string, + ): Promise> { + const result = new Map(); + for (let i = 0; i < emails.length; i += 50) { + const batch = emails.slice(i, i + 50); + const res = await fetch( + "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id", + { + method: "POST", + headers: { + Authorization: `Bearer ${tenantToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ emails: batch, mobiles: [] }), + }, + ); + const json = (await res.json()) as { + code: number; + msg: string; + data?: { + user_list?: Array<{ email?: string; user_id?: string }>; + }; + }; + if (json.code !== 0) { + throw new Error( + `Failed to resolve emails (${batch.length}): ${json.code} ${json.msg}`, + ); + } + for (const entry of json.data?.user_list ?? []) { + if (entry.email && entry.user_id) { + result.set(entry.email, entry.user_id); + } + } + } + return result; + } + /** Reply to a message in a Feishu chat thread. */ async replyMessage( messageId: string, @@ -544,14 +700,58 @@ export class FeishuMessageChannel this._logger.info([sessionId, finalText], "Final Feishu outbound content"); } - private _handleMessageReceive = async ({ - message: receivedMessage, - }: MessageReceiveEventData) => { + private _handleMessageReceive = async ( + eventData: MessageReceiveEventData, + ) => { + const { sender, message: receivedMessage } = eventData; const { message_id: messageId, thread_id: threadId, chat_id: chatId, + chat_type: chatType, + message_type: messageType, + mentions, } = receivedMessage; + const senderOpenId = sender?.sender_id?.open_id; + + const isAllowedSender = + !this._allowedUserOpenIds || + (senderOpenId != null && this._allowedUserOpenIds.has(senderOpenId)); + + const mentionEnforced = this._requireMention && chatType === "group"; + const isBotMentioned = + !!this._botOpenId && + !!mentions?.some((m) => m.id?.open_id === this._botOpenId); + const mentionOk = !mentionEnforced || isBotMentioned; + + this._logger.info( + { + message_id: messageId, + chat_id: chatId, + chat_type: chatType, + message_type: messageType, + sender_open_id: senderOpenId, + bot_mentioned: isBotMentioned, + passed: isAllowedSender && mentionOk, + }, + "inbound message", + ); + + if (!isAllowedSender) { + this._logger.info( + { message_id: messageId, sender_open_id: senderOpenId }, + "dropping inbound: sender not in whitelist", + ); + return; + } + if (!mentionOk) { + this._logger.info( + { message_id: messageId, chat_id: chatId }, + "dropping inbound: bot not @mentioned in group chat", + ); + return; + } + const session_id = this._resolveSessionId(chatId, threadId); const userMessage: UserMessage = { id: messageId, diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 0d4bad9..cbebee0 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -100,6 +100,15 @@ class Kernel { private _initMessageGateway(): void { this._messageGateway = new MultiChannelMessageGateway(this._database.db); for (const channel of config.messaging.channels) { + const splitCsv = (raw: string | undefined) => + (raw ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const allowedOpenIds = splitCsv(channel.params.allowed_user_ids); + const allowedEmails = splitCsv(channel.params.allowed_user_emails); + const requireMention = + (channel.params.require_mention ?? "").toLowerCase() === "true"; this._messageGateway.registerChannel( new FeishuMessageChannel( channel.id, @@ -107,6 +116,11 @@ class Kernel { chatId: channel.params.chat_id!, appId: channel.params.app_id!, appSecret: channel.params.app_secret!, + requireMention, + allowedUserOpenIds: + allowedOpenIds.length > 0 ? allowedOpenIds : undefined, + allowedUserEmails: + allowedEmails.length > 0 ? allowedEmails : undefined, }, this._database.db, ), diff --git a/src/shared/config/schema.ts b/src/shared/config/schema.ts index 2b91983..14549e6 100644 --- a/src/shared/config/schema.ts +++ b/src/shared/config/schema.ts @@ -26,9 +26,26 @@ export const TaskingConfig = z.object({ export interface TaskingConfig extends z.infer {} /** - * Key-value parameters for a messaging channel. + * Key-value parameters for a messaging channel. Accepts string, boolean, + * number, or an array of the same in YAML (e.g. `require_mention: true`, + * `allowed_user_ids: [ou_aaa, ou_bbb]`) and normalizes to strings so downstream + * consumers always work with a uniform `Record` shape. Arrays + * are joined with commas — safe for identifiers that never contain commas + * (open_id, union_id, etc.). */ -export const ChannelParams = z.record(z.string(), z.string()); +export const ChannelParams = z.record( + z.string(), + z + .union([ + z.string(), + z.boolean(), + z.number(), + z.array(z.union([z.string(), z.boolean(), z.number()])), + ]) + .transform((v) => + Array.isArray(v) ? v.map(String).join(",") : String(v), + ), +); export type ChannelParams = z.infer; /** From 798eb012bab15b087d44922d4ee18fb9420fcc2d Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 17:40:34 +0800 Subject: [PATCH 05/69] feat(logging): write per-day runtime log file alongside stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operational logs were stdout-only, so debugging past sessions required re-running the bug or scraping the terminal scrollback. Add a second pino transport that writes to `$AGENTARA_HOME/runtime-logs/YYYY-MM-DD.log` (plain-text, uncolored, append-only). Stdout keeps the pretty-printed output at whatever level AGENTARA_LOG_LEVEL selects; the file always captures `debug` so disk has strictly more detail than the terminal. Path is resolved at startup — a process running across midnight keeps writing to the start-of-day file until restart. Good enough for now. --- src/shared/config/paths.ts | 12 ++++++++++ src/shared/logging/index.ts | 47 ++++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/shared/config/paths.ts b/src/shared/config/paths.ts index c07615b..0fbb5ed 100644 --- a/src/shared/config/paths.ts +++ b/src/shared/config/paths.ts @@ -18,6 +18,18 @@ export function resolveDailyLogFilePath(date: Date) { return join(logs, `${dateString}.md`); } +/** + * Runtime log files written alongside stdout by pino. One file per day, + * `YYYY-MM-DD.log`. Conceptually distinct from `memory/logs/` (which stores + * structured agent diaries); these are operational logs for debugging the + * agentara process itself. + */ +export const runtime_logs = join(home, "runtime-logs"); +export function resolveRuntimeLogFilePath(date: Date) { + const dateString = dayjs(date).format("YYYY-MM-DD"); + return join(runtime_logs, `${dateString}.log`); +} + export const workspace = join(home, "workspace"); export const projects = join(workspace, "projects"); export const uploads = join(workspace, "uploads"); diff --git a/src/shared/logging/index.ts b/src/shared/logging/index.ts index 9943ebe..b54a1a5 100644 --- a/src/shared/logging/index.ts +++ b/src/shared/logging/index.ts @@ -1,5 +1,7 @@ import pino from "pino"; +import * as paths from "../config/paths"; + const VALID_LEVELS = ["trace", "debug", "info", "warn", "error"] as const; function parseLevel(): pino.Level { @@ -12,17 +14,44 @@ function parseLevel(): pino.Level { const isProd = process.env.NODE_ENV === "production"; +/** + * Dual transport: pretty-printed stdout (for dev UX) plus a per-day plain-text + * file under `$AGENTARA_HOME/runtime-logs/YYYY-MM-DD.log`. File output is + * always at `debug` level regardless of stdout level, so disk captures more + * detail than the terminal when we tail back through a past session. + * File path is resolved at startup — log rollover happens only on restart. + */ +const logFilePath = paths.resolveRuntimeLogFilePath(new Date()); + const rootOptions: pino.LoggerOptions = { - level: parseLevel(), - ...{ - transport: { - target: "pino-pretty", - options: { - colorize: true, - translateTime: isProd ? "SYS:MM-DD HH:MM:ss" : "SYS:HH:MM:ss", - ignore: "hostname,pid,topic", + // Root level is the floor — each transport target can narrow but not widen. + // We set root to "trace" so the file target can capture everything while the + // stdout target still respects AGENTARA_LOG_LEVEL. + level: "trace", + transport: { + targets: [ + { + target: "pino-pretty", + level: parseLevel(), + options: { + colorize: true, + translateTime: isProd ? "SYS:MM-DD HH:MM:ss" : "SYS:HH:MM:ss", + ignore: "hostname,pid,topic", + }, + }, + { + target: "pino-pretty", + level: "debug", + options: { + destination: logFilePath, + mkdir: true, + colorize: false, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss", + ignore: "hostname,pid", + append: true, + }, }, - }, + ], }, }; From d22c68ddcb60d4f73372fab2a387115c6e7ac935 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 17:41:06 +0800 Subject: [PATCH 06/69] feat(init): interactive card for batch repo clone + bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `/init` command: when typed in a Feishu group, the bot renders an interactive card listing every entry in the new `predefined_repos` config catalog. Each row is a checker + branch input; a primary-repo selector and a submit button live at the bottom. Submit triggers a card.action.trigger callback that clones the checked repos into the group's workspace, checks out the requested branches, and persists the binding — all while updating the original card in place (pending → final). Specialized, not generic — no reusable "interactive card framework". Key pieces: - `PredefinedRepo` schema + optional top-level `predefined_repos` (empty catalog makes /init respond with a config hint) - New interactive element types for Feishu Card 2.0 form primitives: form, checker, input, button (form_submit), column_set, select_static - `FeishuMessageChannel` gains `sendRawCard` / `updateRawCard` escape hatches and a `card.action.trigger` subscription that normalizes the SDK's provider shape into a `CardActionPayload` and emits `card:action` - The emission is forwarded through `MultiChannelMessageGateway`; kernel routes by `action_name` (only `init_submit` recognized) - `InitFlow` owns the flow end-to-end: pending state keyed by card message_id (in-memory, lost on restart → expired-card toast), initiator-only interaction, sequential clone, `active_branch` falls back to the repo's current HEAD when the requested branch is missing - `/init` is special-cased in `_handleInboundMessage` (like `/stop`) instead of being a regular `CommandHandler`, since a card reply is not a plain string Two non-obvious Feishu constraints hit during development, documented at the sites where they bite: - form_submit buttons must not carry `behaviors: [{ type: callback }]` — combining the two makes Feishu reject the card with "there is no submit button in the form container" - `checker.text` only accepts `plain_text`; `markdown` yields "type of element is not supported tag: markdown" Card callbacks require long-polling subscription to be enabled in the Feishu developer console ("卡片回调走长连接") — otherwise Feishu only delivers card.action.trigger to an HTTP webhook and our handler never fires. --- docs/designs/init-flow.md | 112 +++++ .../feishu/messaging/message-channel.ts | 128 +++++- .../messaging/types/interactive/elements.ts | 108 ++++- src/kernel/commands/handlers.ts | 1 + src/kernel/init/init-card.ts | 173 +++++++ src/kernel/init/init-flow.ts | 429 ++++++++++++++++++ src/kernel/kernel.ts | 66 ++- .../multi-channel-message-gateway.ts | 4 + src/shared/config/index.ts | 7 + src/shared/config/schema.ts | 15 + src/shared/messaging/message-channel.ts | 31 ++ src/shared/messaging/message-gateway.ts | 4 +- src/shared/messaging/types/message.ts | 2 + 13 files changed, 1061 insertions(+), 19 deletions(-) create mode 100644 docs/designs/init-flow.md create mode 100644 src/kernel/init/init-card.ts create mode 100644 src/kernel/init/init-flow.ts diff --git a/docs/designs/init-flow.md b/docs/designs/init-flow.md new file mode 100644 index 0000000..de3f7bf --- /dev/null +++ b/docs/designs/init-flow.md @@ -0,0 +1,112 @@ +# InitFlow Design + +Interactive `/init` command: presents a Feishu card with pre-defined repositories, collects user's repo+branch selections via a form submit, then clones, checks out, and binds the group workspace in one shot. + +Specialized, **not** generic — no broader "interactive card framework". Only `/init` triggers this path. + +## Dependencies + +- `src/shared/config/schema.ts` — new `PredefinedRepo` and optional top-level `predefined_repos` +- `src/community/feishu/messaging/types/interactive/` — new element types (form, checker, input, button, column_set, select_static) +- `src/community/feishu/messaging/message-channel.ts` — subscribe `card.action.trigger`, emit `card:action`, expose `sendRawCard` and `updateRawCard` +- `src/shared/messaging/message-channel.ts` + `message-gateway.ts` — add `"card:action"` event type +- `src/kernel/workspaces/store.ts` — reuse `upsertBinding`, `resolve` +- `src/kernel/kernel.ts` — special-case `/init` like `/stop`; subscribe `card:action` + +## Config + +```yaml +predefined_repos: + - name: agentara + description: 7x24h personal assistant + git_url: https://github.com/xluos/agentara.git +``` + +`predefined_repos` is optional. When unset or empty, `/init` replies with an error asking the operator to configure the catalog. + +## Card Shape + +Single Feishu Card 2.0 form with one row per predefined repo. Each row is a `column_set` with: +- `checker` — `name: repo_`, label is the repo name +- `input` — `name: branch_`, `placeholder: master`, no default value (empty submission means master) +- `markdown` — description text + +Below the rows: a `select_static` for primary-repo selection (`name: primary_repo`, options built from the catalog), always shown. + +Footer: `button` with `action_type: form_submit`, `behaviors.callback.value = { action: "init_submit", init_id }`. + +## Events + +`FeishuMessageChannel` adds a third event handler `card.action.trigger`. It normalizes the SDK payload into a minimal shape: + +```ts +interface CardActionPayload { + message_id: string; + action_name: string; // action.value.action + init_id?: string; // action.value.init_id + operator_open_id: string; // who clicked + form_value: Record;// input/checker values on form_submit + chat_id: string; +} +``` + +Emits as `card:action` on the channel; `MultiChannelMessageGateway` re-emits; kernel dispatches by `action_name` (only `"init_submit"` recognized). + +## API + +### `InitFlow` (src/kernel/init/init-flow.ts) + +```ts +class InitFlow { + constructor(deps: { + workspaceStore: GroupWorkspaceStore; + feishuChannels: Map; // by channel_id + logger: Logger; + }); + + // Entry: called from kernel._handleInboundMessage when text === "/init" + start(message: UserMessage): Promise; + + // Entry: called from kernel card:action listener when action_name === "init_submit" + handleSubmit(payload: CardActionPayload): Promise; +} +``` + +### Pending state + +In-memory `Map` on the `InitFlow` instance: + +```ts +interface PendingInit { + chat_id: string; + initiator_open_id: string; + catalog_snapshot: PredefinedRepo[]; // frozen at card render time + created_at: number; +} +``` + +Lost on kernel restart — acceptable. On submit for an unknown `message_id`, update the card in place with an "expired, please re-run /init" message. + +## Error Handling + +- `/init` when `predefined_repos` is unset/empty → text reply: ask operator to configure. +- `/init` when the group is already bound (`upsertBinding` already present) → text reply: `已绑定 @,请先 /unbind`. +- `/init` outside Feishu (`!message.chat_id`) → text reply: `/init 仅在飞书群内可用`. +- Submit by non-initiator → update card: `这不是你的表单`. +- No repos selected on submit → update card: `未选择任何仓库`. +- Per-repo clone failure → keep successful clones, record the failed entry; show per-repo status in result card. +- Per-repo checkout failure → keep the clone, leave `active_branch` unset if that repo becomes primary; warn on the card. +- All throws from the submit path → update card with `❌ 初始化失败: `. + +No silent fallback. No graceful retry. Every failure surfaces on the card. + +## Concurrency / Constraints + +- Single-writer to pending state: all access through one `InitFlow` instance on the kernel. +- One pending init per `chat_id` — a second `/init` while another is pending replaces the first card's pending entry (in-memory map is keyed by `message_id`, so earlier card becomes unreachable and effectively expired). +- Clone operations run sequentially within one submit, keeping output deterministic and avoiding concurrent writes to the same workspace directory. +- Primary-repo select always offers the full catalog (not just checked rows). If the primary isn't among the checked rows on submit, treat submit as invalid and update card. + +## Result Card + +After submit completes (success or partial), the original card is replaced with a result card (via `updateRawCard`) showing per-repo status (`✅ cloned` / `⚠️ checkout failed` / `❌ clone failed`), the final binding, and next-step hints. diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index ba1d666..9c43979 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -12,14 +12,16 @@ import { createLogger, uuid, type AssistantMessage, + type CardActionPayload, type MessageChannel, type MessageChannelEventTypes, type UserMessage, } from "@/shared"; + import { feishuThreads } from "./data"; import { renderMessageCard, splitMarkdownByTables } from "./message-renderer"; -import type { MessageReceiveEventData } from "./types"; +import type { Card, MessageReceiveEventData } from "./types"; import { convertPostToMarkdown } from "./utils"; function _isFeishuBadRequestError(err: unknown): boolean { @@ -153,14 +155,72 @@ export class FeishuMessageChannel } } + // The node-sdk's `IHandles` type doesn't include card-action events, but + // the underlying `EventDispatcher.invoke` dispatches by event-type string, + // and the WS gateway delivers `card.action.trigger` alongside regular + // events for self-built Feishu apps. We cast through `as never` to bypass + // the typing gap without loosening the strict lookup of typed handlers. await this._inboundClient.start({ eventDispatcher: new EventDispatcher({}).register({ "im.message.receive_v1": this._handleMessageReceive, "im.message.recalled_v1": this._handleMessageRecall, + ["card.action.trigger" as never]: this + ._handleCardAction as never, }), }); } + /** + * Send a raw Feishu interactive card to a chat. Escape hatch used by + * commands that render custom cards (e.g. `/init`) outside the normal + * AssistantMessage pipeline. Returns the posted message's id so the caller + * can correlate later card actions / updates. + */ + async sendRawCard( + chatId: string, + card: Card, + options: { replyTo?: string } = {}, + ): Promise { + if (options.replyTo) { + const { data } = await this._client.im.message.reply({ + path: { message_id: options.replyTo }, + data: { + msg_type: "interactive", + content: JSON.stringify(card), + reply_in_thread: true, + }, + }); + if (!data?.message_id) { + throw new Error("Failed to reply with interactive card"); + } + return data.message_id; + } + const { data } = await this._client.im.message.create({ + params: { receive_id_type: "chat_id" }, + data: { + receive_id: chatId, + msg_type: "interactive", + content: JSON.stringify(card), + }, + }); + if (!data?.message_id) { + throw new Error("Failed to post interactive card"); + } + return data.message_id; + } + + /** + * Replace the content of an existing interactive card message. Used by + * card-driven flows to transition the same message from "pending" to + * "completed" without spawning a new reply. + */ + async updateRawCard(messageId: string, card: Card): Promise { + await this._client.im.message.patch({ + path: { message_id: messageId }, + data: { content: JSON.stringify(card) }, + }); + } + /** * Exchange app credentials for a tenant access token. Used for REST calls * that the node-sdk doesn't expose directly (bot info, email→id lookup). @@ -760,6 +820,7 @@ export class FeishuMessageChannel channel_id: this.id, chat_id: chatId, thread_id: threadId, + sender_open_id: senderOpenId, content: [ await this._parseMessageContent( messageId, @@ -782,6 +843,71 @@ export class FeishuMessageChannel this.emit("message:recalled", data.message_id, this.id); }; + /** + * Handle a `card.action.trigger` event delivered via the WS event stream. + * Normalizes the provider-specific shape into `CardActionPayload` and emits + * `card:action`; the kernel dispatches by `action_name`. + */ + private _handleCardAction = async (data: { + operator?: { open_id?: string; tenant_key?: string }; + action?: { + value?: Record; + form_value?: Record; + tag?: string; + name?: string; + }; + context?: { open_message_id?: string; open_chat_id?: string }; + }) => { + const messageId = data.context?.open_message_id; + const operatorOpenId = data.operator?.open_id; + if (!messageId || !operatorOpenId) { + this._logger.warn( + { data }, + "ignoring card.action.trigger with missing message_id/operator", + ); + return; + } + const value = data.action?.value ?? {}; + // `action.value.action` is set for callback buttons (`behaviors[].value`). + // For form_submit buttons we don't attach behaviors, so fall back to the + // submit button's `name`, which Feishu echoes at `action.name`. That makes + // the submit-button name the de-facto action discriminator for forms. + const actionName = + typeof value.action === "string" + ? value.action + : typeof data.action?.name === "string" + ? data.action.name + : ""; + const payload: CardActionPayload = { + message_id: messageId, + channel_id: this.id, + chat_id: data.context?.open_chat_id, + operator_open_id: operatorOpenId, + action_name: actionName, + value, + form_value: data.action?.form_value ?? {}, + }; + this._logger.info( + { + message_id: messageId, + action_name: actionName, + operator_open_id: operatorOpenId, + form_value: data.action?.form_value, + raw_value: value, + }, + "card action", + ); + this.emit("card:action", payload); + // Acknowledge the action back to Feishu via the WS response (the SDK + // base64-encodes this as respPayload.data). Without an ack, the card UI + // can surface a generic failure toast while the real work happens + // asynchronously. Handlers downstream update the card in-place via + // `updateRawCard` when done. + return { + toast: { type: "info", content: "已收到,正在处理…" }, + }; + }; + private _threadIdToSessionId = new Map(); /** Persist a thread→session mapping to DB and update the in-memory cache. */ diff --git a/src/community/feishu/messaging/types/interactive/elements.ts b/src/community/feishu/messaging/types/interactive/elements.ts index d674686..7cee136 100644 --- a/src/community/feishu/messaging/types/interactive/elements.ts +++ b/src/community/feishu/messaging/types/interactive/elements.ts @@ -76,9 +76,115 @@ export interface CollapsiblePanel extends BaseContainer<"collapsible_panel"> { }; } +/** + * Generic callback value on an interactive element. Our `/init` flow uses + * `{ action: string; init_id: string; ... }`, but we keep the shape open so + * future interactive commands can attach whatever discriminator they need. + * The server receives this verbatim on `card.action.trigger`. + */ +export type CallbackValue = Record; + +export interface CallbackBehavior { + type: "callback"; + value: CallbackValue; +} + +/** + * Button. Two modes: + * - `action_type: "form_submit"` — submits the enclosing form; the triggered + * action payload includes `form_value` (a map of every named input/checker + * inside the form). + * - no `action_type` + `behaviors: [{ type: "callback", value }]` — a standalone + * callback that delivers `value` as `action.value` on the server event. + */ +export interface ButtonElement extends BaseElement<"button"> { + name?: string; + text: PlainTextElement; + type?: "default" | "primary" | "danger" | "text"; + action_type?: "form_submit" | "form_reset"; + behaviors?: CallbackBehavior[]; + width?: string; +} + +/** + * Single-line text input. `name` is the field key echoed back in the form + * submission's `form_value`. + */ +export interface InputElement extends BaseElement<"input"> { + name: string; + placeholder?: PlainTextElement; + default_value?: string; + width?: string; + max_length?: number; +} + +/** + * Single toggle checkbox. `name` is the field key; `checked` is the initial + * state; on form submission, the server echoes a boolean at `form_value[name]`. + * + * Note: Feishu requires `text` to be `plain_text`. Passing `markdown` yields + * "type of element is not supported tag: markdown" (error 200621). + */ +export interface CheckerElement extends BaseElement<"checker"> { + name: string; + text: PlainTextElement; + checked?: boolean; +} + +export interface SelectOption { + text: PlainTextElement; + value: string; +} + +/** + * Single-select dropdown. On form submission the selected `value` is echoed at + * `form_value[name]`. + */ +export interface SelectStaticElement extends BaseElement<"select_static"> { + name: string; + placeholder?: PlainTextElement; + initial_option?: string; + options: SelectOption[]; + width?: string; +} + +/** One column inside a `column_set`. */ +export interface Column extends BaseContainer<"column"> { + width?: "auto" | "weighted" | "fill" | string; + weight?: number; + vertical_align?: "top" | "center" | "bottom"; + vertical_spacing?: string; + padding?: string; +} + +/** + * Horizontal row of columns. Useful for placing a checker next to its input + * and description on a single card row. + */ +export interface ColumnSetElement extends BaseElement<"column_set"> { + flex_mode?: "none" | "stretch" | "flow" | "bisect" | "trisect"; + horizontal_spacing?: string; + columns: Column[]; +} + +/** + * Form container. Any inputs/checkers/buttons placed inside contribute to the + * form's `form_value` on submit. `name` identifies the form on the server side. + */ +export interface FormElement extends BaseContainer<"form"> { + name: string; +} + export type Element = + | ButtonElement + | CheckerElement | CollapsiblePanel + | Column + | ColumnSetElement | DivElement + | FormElement | IconElement + | InputElement | MarkdownElement - | PlainTextElement; + | PlainTextElement + | SelectStaticElement; diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 28736c9..013d275 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -211,6 +211,7 @@ export const helpHandler: CommandHandler = { ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), "- /help — 显示本消息", "- /stop — 取消当前 session 正在执行的任务", + "- /init — 打开交互卡片,从预定义仓库目录中批量克隆并绑定", ].join("\n"); }, }; diff --git a/src/kernel/init/init-card.ts b/src/kernel/init/init-card.ts new file mode 100644 index 0000000..14ec7d0 --- /dev/null +++ b/src/kernel/init/init-card.ts @@ -0,0 +1,173 @@ +import type { PredefinedRepo } from "@/shared"; + +import type { + ButtonElement, + Card, + CheckerElement, + ColumnSetElement, + Element, + FormElement, + InputElement, + MarkdownElement, + SelectStaticElement, +} from "../../community/feishu/messaging/types"; + +/** + * Field naming convention used by both the card renderer and the submit + * handler. Keep them in one place so the two sides cannot drift apart. + */ +export const INIT_FIELD = { + repoChecker: (name: string) => `repo_${name}`, + branchInput: (name: string) => `branch_${name}`, + primaryRepo: "primary_repo", +} as const; + +/** + * Build the interactive `/init` card. + * + * Layout: + * - Header: prompt text + * - Form body: one row per predefined repo (checker + branch input + description) + * - Primary-repo selector (always shown; default = first repo in catalog) + * - Submit button ("初始化") with `action_type: "form_submit"`. On submit the + * server receives `action.name = "init_submit"` and `action.form_value` + * carries the checker + input + select values. + * + * Card-to-pending correlation happens on the kernel side via `message_id`, + * so the card itself carries no init_id. + */ +export function buildInitCard(catalog: PredefinedRepo[]): Card { + const formElements: Element[] = []; + + for (const repo of catalog) { + formElements.push(_buildRepoRow(repo)); + } + + formElements.push(_buildPrimarySelect(catalog)); + formElements.push(_buildSubmitButton()); + + const form: FormElement = { + tag: "form", + name: "init_form", + elements: formElements, + }; + + const header: MarkdownElement = { + tag: "markdown", + content: [ + "**📦 初始化当前群的 workspace**", + "", + "勾选要克隆的仓库;分支默认 `master`,留空即使用 master。", + "选多个仓库时,请在底部选一个作为「主仓库」(后续消息的默认仓库)。", + ].join("\n"), + }; + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: "📦 初始化 workspace" }, + }, + body: { + elements: [header, form], + }, + }; +} + +function _buildRepoRow(repo: PredefinedRepo): ColumnSetElement { + // NOTE: Feishu's checker.text ONLY accepts `plain_text`, not `markdown`. + // Attempting markdown yields "type of element is not supported tag: markdown" + // (error 200621). We inline the description into the label as a plain string. + const label = repo.description + ? `${repo.name} — ${repo.description}` + : repo.name; + const checker: CheckerElement = { + tag: "checker", + name: INIT_FIELD.repoChecker(repo.name), + text: { tag: "plain_text", content: label }, + checked: false, + }; + const branchInput: InputElement = { + tag: "input", + name: INIT_FIELD.branchInput(repo.name), + placeholder: { tag: "plain_text", content: "master" }, + width: "fill", + }; + + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "8px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [checker] }, + { tag: "column", width: "140px", elements: [branchInput] }, + ], + }; +} + +function _buildPrimarySelect(catalog: PredefinedRepo[]): SelectStaticElement { + return { + tag: "select_static", + name: INIT_FIELD.primaryRepo, + placeholder: { tag: "plain_text", content: "选择主仓库(默认第一个)" }, + initial_option: catalog[0]?.name, + options: catalog.map((r) => ({ + text: { tag: "plain_text", content: r.name }, + value: r.name, + })), + width: "fill", + }; +} + +function _buildSubmitButton(): ButtonElement { + // IMPORTANT: use `action_type: "form_submit"` alone — do NOT combine with + // `behaviors: [{ type: "callback", ... }]`. Feishu's validator requires at + // least one recognizable submit button inside a form container, and a + // `callback` behavior makes the button look like a plain callback button + // instead, producing "there is no submit button in the form container". + // + // The init flow correlates the submit event by `message_id` (we keep + // pending state keyed by the card's message id), so the button does not + // need to carry init_id itself. + return { + tag: "button", + name: "init_submit", + text: { tag: "plain_text", content: "✅ 初始化" }, + type: "primary", + action_type: "form_submit", + }; +} + +/** + * Result card rendered after the submit handler finishes. Replaces the + * original card in place via `updateRawCard`. + */ +export function buildInitResultCard( + summary: string, + perRepoLines: string[], +): Card { + const elements: Element[] = [ + { + tag: "markdown", + content: summary, + }, + ]; + if (perRepoLines.length > 0) { + elements.push({ + tag: "markdown", + content: perRepoLines.join("\n"), + }); + } + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: summary.slice(0, 80) }, + }, + body: { elements }, + }; +} diff --git a/src/kernel/init/init-flow.ts b/src/kernel/init/init-flow.ts new file mode 100644 index 0000000..0ab57fc --- /dev/null +++ b/src/kernel/init/init-flow.ts @@ -0,0 +1,429 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import type { Logger } from "@/shared"; +import { + config, + createLogger, + type CardActionPayload, + type PredefinedRepo, + type UserMessage, +} from "@/shared"; + +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; +import type { GroupWorkspaceStore } from "../workspaces"; + +import { buildInitCard, buildInitResultCard, INIT_FIELD } from "./init-card"; + +/** + * In-memory pending state for a `/init` card that has been sent but not yet + * submitted. Dropped on kernel restart — expired cards surface a clear error + * back to the user instead of being silently honored. + */ +interface PendingInit { + chat_id: string; + initiator_open_id: string; + catalog_snapshot: PredefinedRepo[]; + created_at: number; +} + +interface RepoResult { + name: string; + /** The branch the user requested in the form. */ + branch: string; + /** + * The branch the repo is actually on after clone+checkout. Equal to `branch` + * on success; on `checkout_failed` it falls back to whatever the clone + * landed on (the repo's default branch). Unset only on `clone_failed`. + */ + actual_branch?: string; + status: "cloned" | "exists" | "clone_failed" | "checkout_failed"; + detail?: string; +} + +/** + * Stateful orchestrator for the `/init` interactive flow. + * + * Lifecycle per invocation: + * 1. `start(message)` renders the card and remembers the catalog snapshot + * keyed by the outbound message id. + * 2. The kernel routes a `card:action` with `action_name === "init_submit"` + * to `handleSubmit(payload)`. + * 3. The handler clones the selected repos, checks out the requested branches, + * upserts the group binding, and replaces the original card with a result + * card via `updateRawCard`. + * + * Single-writer: all pending-state access stays on this instance. One pending + * init per chat is implicit — issuing `/init` again replaces the key. + */ +export class InitFlow { + private readonly _logger: Logger = createLogger("init-flow"); + private readonly _workspaceStore: GroupWorkspaceStore; + private readonly _feishuChannels: Map; + private readonly _pending = new Map(); + + constructor(deps: { + workspaceStore: GroupWorkspaceStore; + feishuChannels: Map; + }) { + this._workspaceStore = deps.workspaceStore; + this._feishuChannels = deps.feishuChannels; + } + + /** + * Entry point invoked from `kernel._handleInboundMessage` when the inbound + * text is `/init`. Sends back either a plain-text error (no catalog, already + * bound, ...) or the interactive card. + */ + async start(message: UserMessage): Promise { + const chatId = message.chat_id; + if (!chatId || !message.channel_id) { + await this._replyText(message, "❌ /init 仅在飞书群内可用。"); + return; + } + const catalog = config.predefined_repos; + if (catalog.length === 0) { + await this._replyText( + message, + "❌ 未配置 `predefined_repos`,请先在 `config.yaml` 里加上仓库目录。", + ); + return; + } + const existing = this._workspaceStore.getBinding(chatId); + if (existing) { + const repo = existing.active_repo ?? "(未设置)"; + const branch = existing.active_branch ?? "(未设置)"; + await this._replyText( + message, + `❌ 当前群已绑定 \`${repo}\` @ \`${branch}\`,请先 \`/unbind\`。`, + ); + return; + } + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) { + await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); + return; + } + + const card = buildInitCard(catalog); + const cardMessageId = await channel.sendRawCard(chatId, card, { + replyTo: message.id, + }); + this._pending.set(cardMessageId, { + chat_id: chatId, + initiator_open_id: message.sender_open_id ?? "", + catalog_snapshot: catalog, + created_at: Date.now(), + }); + this._logger.info( + { chat_id: chatId, card_message_id: cardMessageId }, + "init card sent", + ); + } + + /** + * Entry point invoked from the kernel's `card:action` listener when the + * payload's `action_name === "init_submit"`. Looks up the pending state by + * `payload.message_id` and either rejects the submission (expired, wrong + * user, invalid selection) or runs the clone+bind sequence. + */ + async handleSubmit(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id }, + "received card action for unknown channel", + ); + return; + } + const pending = this._pending.get(payload.message_id); + if (!pending) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildInitResultCard( + "⚠️ 这张卡片已失效,请重新发送 `/init`。", + [], + ), + "expired", + ); + return; + } + if ( + pending.initiator_open_id && + payload.operator_open_id !== pending.initiator_open_id + ) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildInitResultCard("🚫 这不是你的表单。", []), + "non-initiator", + ); + return; + } + + this._pending.delete(payload.message_id); + + const selections = this._parseFormValue( + payload.form_value, + pending.catalog_snapshot, + ); + if (selections.length === 0) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildInitResultCard("⚠️ 未选择任何仓库,请重新发送 `/init`。", []), + "empty-selection", + ); + return; + } + + const rawPrimary = + typeof payload.form_value[INIT_FIELD.primaryRepo] === "string" + ? (payload.form_value[INIT_FIELD.primaryRepo] as string) + : ""; + // `selections` is guaranteed non-empty by the earlier length-check return. + const firstSel = selections[0]!; + const primary = selections.find((s) => s.name === rawPrimary) + ? rawPrimary + : firstSel.name; + + // Swap the card to a "working" state immediately so the user sees that + // the submit landed, then run clones (possibly long) before the final + // update. If this patch fails, we still proceed to the clone work. + await this._tryUpdateCard( + channel, + payload.message_id, + buildInitResultCard( + `⏳ 正在初始化 \`${selections.map((s) => s.name).join("、")}\`…`, + selections.map( + (s) => `- \`${s.name}\` @ \`${s.branch}\``, + ), + ), + "pending-state", + ); + + const workspacePath = config.paths.resolveGroupWorkspacePath( + pending.chat_id, + ); + if (!existsSync(workspacePath)) { + const { mkdirSync } = await import("node:fs"); + mkdirSync(workspacePath, { recursive: true }); + } + + const results: RepoResult[] = []; + for (const sel of selections) { + results.push(await this._cloneAndCheckout(workspacePath, sel)); + } + + const primaryResult = results.find((r) => r.name === primary); + // active_branch comes from `actual_branch`, which is always set unless the + // clone itself failed. If the requested branch didn't exist, we bind to + // whatever the clone landed on (usually the remote HEAD) instead of null. + const activeRepo = + primaryResult && primaryResult.status !== "clone_failed" ? primary : null; + const activeBranch = primaryResult?.actual_branch ?? null; + + this._workspaceStore.upsertBinding(pending.chat_id, { + active_repo: activeRepo, + active_branch: activeBranch, + }); + + const lines = results.map(_formatResultLine); + const summary = activeRepo && activeBranch + ? `✅ 初始化完成,主仓库 \`${activeRepo}\` @ \`${activeBranch}\`。` + : "❌ 所有仓库克隆失败,未建立绑定。"; + await this._tryUpdateCard( + channel, + payload.message_id, + buildInitResultCard(summary, lines), + "final-result", + ); + this._logger.info( + { + chat_id: pending.chat_id, + primary, + results: results.map((r) => ({ name: r.name, status: r.status })), + }, + "init submit completed", + ); + } + + /** + * `updateRawCard` wrapper that logs the Feishu error body instead of + * crashing the handleSubmit flow. On failure we press on — the user at + * least knows clone ran from logs, even if the UI card is stuck. + */ + private async _tryUpdateCard( + channel: FeishuMessageChannel, + messageId: string, + card: ReturnType, + stage: string, + ): Promise { + try { + await channel.updateRawCard(messageId, card); + } catch (err) { + const detail = _summarizeFeishuError(err); + this._logger.error( + { err: detail, stage, message_id: messageId }, + "updateRawCard failed", + ); + } + } + + private _parseFormValue( + formValue: Record, + catalog: PredefinedRepo[], + ): Array<{ repo: PredefinedRepo; name: string; branch: string }> { + const out: Array<{ repo: PredefinedRepo; name: string; branch: string }> = + []; + for (const repo of catalog) { + const rawChecked = formValue[INIT_FIELD.repoChecker(repo.name)]; + if (!_isTruthyChecker(rawChecked)) continue; + const rawBranch = formValue[INIT_FIELD.branchInput(repo.name)]; + const branch = + typeof rawBranch === "string" && rawBranch.trim() + ? rawBranch.trim() + : "master"; + out.push({ repo, name: repo.name, branch }); + } + return out; + } + + private async _cloneAndCheckout( + workspacePath: string, + sel: { repo: PredefinedRepo; name: string; branch: string }, + ): Promise { + const targetPath = join(workspacePath, sel.name); + const alreadyCloned = existsSync(join(targetPath, ".git")); + + if (!alreadyCloned) { + const clone = await _execGit( + ["clone", sel.repo.git_url, sel.name], + workspacePath, + ); + if (!clone.ok) { + return { + name: sel.name, + branch: sel.branch, + status: "clone_failed", + detail: clone.stderr || clone.stdout, + }; + } + } + + const co = await _execGit(["checkout", sel.branch], targetPath); + if (!co.ok) { + // Fall back to the branch the repo is currently on so the binding still + // has an `active_branch` instead of leaving it blank. + const head = await _execGit( + ["rev-parse", "--abbrev-ref", "HEAD"], + targetPath, + ); + const fallbackBranch = head.ok && head.stdout ? head.stdout : sel.branch; + return { + name: sel.name, + branch: sel.branch, + actual_branch: fallbackBranch, + status: "checkout_failed", + detail: co.stderr || co.stdout, + }; + } + + return { + name: sel.name, + branch: sel.branch, + actual_branch: sel.branch, + status: alreadyCloned ? "exists" : "cloned", + }; + } + + private async _replyText( + message: UserMessage, + text: string, + ): Promise { + if (!message.channel_id) return; + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) return; + await channel.replyMessage(message.id, { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }); + } +} + +function _isTruthyChecker(v: unknown): boolean { + if (v === true) return true; + if (typeof v === "string") { + const lower = v.toLowerCase(); + return lower === "true" || lower === "1" || lower === "on"; + } + return false; +} + +function _formatResultLine(r: RepoResult): string { + switch (r.status) { + case "cloned": + return `- ✅ \`${r.name}\` @ \`${r.branch}\` 已克隆`; + case "exists": + return `- ℹ️ \`${r.name}\` 已存在,已切换到 \`${r.branch}\``; + case "checkout_failed": + return ( + `- ⚠️ \`${r.name}\` 已克隆,分支 \`${r.branch}\` 不可切换,` + + `保留在 \`${r.actual_branch ?? "(未知)"}\`` + + (r.detail ? `:${_compressDetail(r.detail)}` : "") + ); + case "clone_failed": + return `- ❌ \`${r.name}\` 克隆失败${ + r.detail ? `:${_compressDetail(r.detail)}` : "" + }`; + } +} + +function _compressDetail(detail: string): string { + const first = detail.split("\n").find((l) => l.trim()) ?? ""; + return first.length > 120 ? first.slice(0, 120) + "…" : first; +} + +/** + * Extract the useful bits out of a Feishu/Axios error so we can see the + * actual server error code + message in the logs without dumping the whole + * request/response graph. + */ +function _summarizeFeishuError( + err: unknown, +): { code?: number; msg?: string; status?: number; raw?: unknown } { + if (!err || typeof err !== "object") { + return { raw: err }; + } + const candidate = err as { + response?: { + status?: number; + data?: { code?: number; msg?: string }; + }; + message?: string; + }; + return { + code: candidate.response?.data?.code, + msg: candidate.response?.data?.msg ?? candidate.message, + status: candidate.response?.status, + }; +} + +async function _execGit( + args: string[], + cwd: string, +): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code }; +} diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index cbebee0..c86be5f 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -1,7 +1,7 @@ import { FeishuMessageChannel } from "@/community/feishu"; import * as feishuMessagingSchema from "@/community/feishu/messaging/data"; import { DataConnection } from "@/data"; -import type { AssistantMessage, UserMessage } from "@/shared"; +import type { AssistantMessage, CardActionPayload, UserMessage } from "@/shared"; import { config, createLogger, @@ -14,6 +14,7 @@ import { import { HonoServer } from "../server"; import { CommandRegistry, parseCommand } from "./commands"; +import { InitFlow } from "./init/init-flow"; import { MultiChannelMessageGateway } from "./messaging"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; @@ -34,6 +35,8 @@ class Kernel { private _honoServer!: HonoServer; private _workspaceStore!: GroupWorkspaceStore; private _commandRegistry!: CommandRegistry; + private _feishuChannels = new Map(); + private _initFlow!: InitFlow; constructor() { this._initDatabase(); @@ -42,6 +45,7 @@ class Kernel { this._initCommandRegistry(); this._initTaskDispatcher(); this._initMessageGateway(); + this._initInitFlow(); this._initServer(); } @@ -109,25 +113,33 @@ class Kernel { const allowedEmails = splitCsv(channel.params.allowed_user_emails); const requireMention = (channel.params.require_mention ?? "").toLowerCase() === "true"; - this._messageGateway.registerChannel( - new FeishuMessageChannel( - channel.id, - { - chatId: channel.params.chat_id!, - appId: channel.params.app_id!, - appSecret: channel.params.app_secret!, - requireMention, - allowedUserOpenIds: - allowedOpenIds.length > 0 ? allowedOpenIds : undefined, - allowedUserEmails: - allowedEmails.length > 0 ? allowedEmails : undefined, - }, - this._database.db, - ), + const feishuChannel = new FeishuMessageChannel( + channel.id, + { + chatId: channel.params.chat_id!, + appId: channel.params.app_id!, + appSecret: channel.params.app_secret!, + requireMention, + allowedUserOpenIds: + allowedOpenIds.length > 0 ? allowedOpenIds : undefined, + allowedUserEmails: + allowedEmails.length > 0 ? allowedEmails : undefined, + }, + this._database.db, ); + this._feishuChannels.set(channel.id, feishuChannel); + this._messageGateway.registerChannel(feishuChannel); } this._messageGateway.on("message:inbound", this._handleInboundMessage); this._messageGateway.on("message:recalled", this._handleMessageRecall); + this._messageGateway.on("card:action", this._handleCardAction); + } + + private _initInitFlow(): void { + this._initFlow = new InitFlow({ + workspaceStore: this._workspaceStore, + feishuChannels: this._feishuChannels, + }); } /** @@ -153,6 +165,13 @@ class Kernel { return; } + // Handle /init command (kernel-owned — renders an interactive card and + // awaits a card:action callback rather than returning a plain text reply). + if (text === "/init") { + await this._initFlow.start(message); + return; + } + // Try gateway-level slash commands before dispatching to the LLM. if (text.startsWith("/")) { const handled = await this._tryHandleCommand(message, text); @@ -245,6 +264,21 @@ class Kernel { } }; + /** + * Route card-action callbacks by the `action_name` discriminator. Right now + * only `/init` produces callbacks; unknown actions are logged and dropped. + */ + private _handleCardAction = async (payload: CardActionPayload) => { + if (payload.action_name === "init_submit") { + await this._initFlow.handleSubmit(payload); + return; + } + this._logger.warn( + { action_name: payload.action_name, message_id: payload.message_id }, + "unhandled card action", + ); + }; + private _handleInboundMessageTask = async ( taskId: string, sessionId: string, diff --git a/src/kernel/messaging/multi-channel-message-gateway.ts b/src/kernel/messaging/multi-channel-message-gateway.ts index 7f13640..fc553d3 100644 --- a/src/kernel/messaging/multi-channel-message-gateway.ts +++ b/src/kernel/messaging/multi-channel-message-gateway.ts @@ -4,6 +4,7 @@ import EventEmitter from "eventemitter3"; import type { DrizzleDB } from "@/data"; import type { AssistantMessage, + CardActionPayload, MessageChannel, MessageGateway, MessageGatewayEventTypes, @@ -47,6 +48,9 @@ export class MultiChannelMessageGateway channel.on("message:recalled", (messageId: string, channelId: string) => { this.emit("message:recalled", messageId, channelId); }); + channel.on("card:action", (payload: CardActionPayload) => { + this.emit("card:action", payload); + }); this._logger.info(`Registered channel: ${channel.id}`); } diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index 6bba5a2..83134cc 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -13,6 +13,7 @@ export type { ChannelConfig, ChannelParams, MessagingConfig, + PredefinedRepo, TaskingConfig, } from "./schema"; @@ -88,5 +89,11 @@ export const config = { } return _appConfig.messaging; }, + get predefined_repos() { + if (!_appConfig) { + return []; + } + return _appConfig.predefined_repos; + }, paths, }; diff --git a/src/shared/config/schema.ts b/src/shared/config/schema.ts index 14549e6..41a5f45 100644 --- a/src/shared/config/schema.ts +++ b/src/shared/config/schema.ts @@ -69,6 +69,19 @@ export const MessagingConfig = z.object({ }); export interface MessagingConfig extends z.infer {} +/** + * A pre-defined repository surfaced by the `/init` command. Operators curate + * this catalog in `config.yaml`; `/init` renders it as a Feishu interactive + * card for the user to pick which repos (and branches) to clone into the + * group's workspace. + */ +export const PredefinedRepo = z.object({ + name: z.string(), + description: z.string().default(""), + git_url: z.string(), +}); +export interface PredefinedRepo extends z.infer {} + /** * Top-level application configuration loaded from config.yaml. */ @@ -80,5 +93,7 @@ export const AppConfig = z.object({ agents: AgentsConfig, tasking: TaskingConfig, messaging: MessagingConfig, + /** Optional catalog for `/init`. Empty/unset means the command is disabled. */ + predefined_repos: z.array(PredefinedRepo).default([]), }); export interface AppConfig extends z.infer {} diff --git a/src/shared/messaging/message-channel.ts b/src/shared/messaging/message-channel.ts index 5da67fe..37f2cff 100644 --- a/src/shared/messaging/message-channel.ts +++ b/src/shared/messaging/message-channel.ts @@ -2,12 +2,43 @@ import type EventEmitter from "eventemitter3"; import type { AssistantMessage, UserMessage } from "./types"; +/** + * Payload delivered when a user interacts with an interactive card. The + * channel normalizes the provider-specific event (for Feishu: + * `card.action.trigger`) into this shape before re-emitting. + */ +export interface CardActionPayload { + /** ID of the card message the user interacted with. */ + message_id: string; + /** Channel that delivered the event. */ + channel_id: string; + /** Provider-specific chat/group identifier, if applicable. */ + chat_id?: string; + /** open_id of the user who clicked. */ + operator_open_id: string; + /** + * Action discriminator. For our own cards, this is set via + * `behaviors[].value.action` on the triggering element. Commands use it to + * route the event (e.g. `"init_submit"`). + */ + action_name: string; + /** The full `behaviors[].value` dict, passed through verbatim. */ + value: Record; + /** + * For `action_type: "form_submit"` — values of every named field inside the + * enclosing form (input/checker/select). Empty for non-form actions. + */ + form_value: Record; +} + /** Event types emitted by a message channel. */ export interface MessageChannelEventTypes { // eslint-disable-next-line no-unused-vars "message:inbound": (message: UserMessage) => void; // eslint-disable-next-line no-unused-vars "message:recalled": (messageId: string, channelId: string) => void; + // eslint-disable-next-line no-unused-vars + "card:action": (payload: CardActionPayload) => void; } /** Abstract message channel for sending and receiving messages. */ diff --git a/src/shared/messaging/message-gateway.ts b/src/shared/messaging/message-gateway.ts index 075330b..3d7739b 100644 --- a/src/shared/messaging/message-gateway.ts +++ b/src/shared/messaging/message-gateway.ts @@ -1,6 +1,6 @@ import type EventEmitter from "eventemitter3"; -import type { MessageChannel } from "./message-channel"; +import type { CardActionPayload, MessageChannel } from "./message-channel"; import type { AssistantMessage, UserMessage } from "./types"; /** Event types emitted by a message gateway. */ @@ -9,6 +9,8 @@ export interface MessageGatewayEventTypes { "message:inbound": (message: UserMessage) => void; // eslint-disable-next-line no-unused-vars "message:recalled": (messageId: string, channelId: string) => void; + // eslint-disable-next-line no-unused-vars + "card:action": (payload: CardActionPayload) => void; } /** diff --git a/src/shared/messaging/types/message.ts b/src/shared/messaging/types/message.ts index 62d6a84..33894ad 100644 --- a/src/shared/messaging/types/message.ts +++ b/src/shared/messaging/types/message.ts @@ -50,6 +50,8 @@ export const UserMessage = BaseMessage.extend({ chat_id: z.string().optional(), /** Feishu topic/thread id, when the message is inside a topic. */ thread_id: z.string().optional(), + /** Provider-specific open_id of the sender (e.g. Feishu open_id). */ + sender_open_id: z.string().optional(), content: z.array( z.discriminatedUnion("type", [ TextMessageContent, From 891d9f1d384f69c306b446b62cdf6dc4268c05d5 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 18:13:59 +0800 Subject: [PATCH 07/69] feat(commands): inline slash-command replies; scope @mention-strip to session start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small UX fixes to the slash-command flow that would otherwise pile noise into Feishu topic threads: 1. `reply_in_thread` becomes opt-in on MessageGateway/MessageChannel `replyMessage` (new `replyInThread` option, default `true` to keep session flow untouched). All slash-command reply paths — `_tryHandleCommand`, `_handleStopCommand`, `InitFlow._replyText` — pass `false` so they show up inline in the conversation list instead of spawning a new topic per command. `sendRawCard` flips its default to inline since the only caller (/init) is a command-originated card. `_sendRemainingChunks` carries the flag through so chunk follow-ups match the parent reply's mode. 2. `@_user_N` stripping in `_handleInboundMessage` now only runs on the first message of a session (detected via `sessionManager.existsSession`). The bot needs the strip when a user @-summons it to start a thread (`@bot /bind foo`), but once inside the thread, real @-mentions of other people should survive intact. --- .../feishu/messaging/message-channel.ts | 44 ++++++++++++++----- src/kernel/init/init-flow.ts | 14 +++--- src/kernel/kernel.ts | 35 +++++++++++---- .../multi-channel-message-gateway.ts | 6 ++- src/shared/messaging/message-channel.ts | 8 +++- src/shared/messaging/message-gateway.ts | 14 ++++-- 6 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 9c43979..143ff25 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -175,11 +175,15 @@ export class FeishuMessageChannel * commands that render custom cards (e.g. `/init`) outside the normal * AssistantMessage pipeline. Returns the posted message's id so the caller * can correlate later card actions / updates. + * + * `options.replyInThread` defaults to `false`: command-originated cards + * should appear inline in the chat rather than opening a new topic. Pass + * `true` explicitly if the flow is session-scoped. */ async sendRawCard( chatId: string, card: Card, - options: { replyTo?: string } = {}, + options: { replyTo?: string; replyInThread?: boolean } = {}, ): Promise { if (options.replyTo) { const { data } = await this._client.im.message.reply({ @@ -187,7 +191,7 @@ export class FeishuMessageChannel data: { msg_type: "interactive", content: JSON.stringify(card), - reply_in_thread: true, + reply_in_thread: options.replyInThread ?? false, }, }); if (!data?.message_id) { @@ -316,11 +320,23 @@ export class FeishuMessageChannel return result; } - /** Reply to a message in a Feishu chat thread. */ + /** + * Reply to a message. Defaults to opening a new Feishu topic + * (`reply_in_thread: true`) because most replies are session-scoped + * assistant output. Pass `replyInThread: false` for one-shot replies (slash + * commands, quick error messages) that should render inline instead. + * + * When `replyInThread` is false, the thread→session mapping is skipped — + * there's no new thread to map, and the reply doesn't belong to any + * session anyway. + */ async replyMessage( messageId: string, message: Omit, - { streaming = true }: { streaming?: boolean } = {}, + { + streaming = true, + replyInThread = true, + }: { streaming?: boolean; replyInThread?: boolean } = {}, ): Promise { const { firstMessageContent, remainingChunks } = this._prepareMessageContent( message.content, @@ -341,18 +357,25 @@ export class FeishuMessageChannel data: { msg_type: "interactive", content: JSON.stringify(card), - reply_in_thread: true, + reply_in_thread: replyInThread, }, }); if (!replyMessage) { throw new Error("Failed to reply message"); } - const { thread_id: threadId } = replyMessage; - const sessionId = message.session_id; - this._mapThreadToSession(threadId!, sessionId); + if (replyInThread) { + const { thread_id: threadId } = replyMessage; + if (threadId) { + this._mapThreadToSession(threadId, message.session_id); + } + } - await this._sendRemainingChunks(replyMessage.message_id!, remainingChunks); + await this._sendRemainingChunks( + replyMessage.message_id!, + remainingChunks, + replyInThread, + ); const assistantMessage = message as AssistantMessage; assistantMessage.id = replyMessage.message_id!; @@ -659,6 +682,7 @@ export class FeishuMessageChannel private async _sendRemainingChunks( messageId: string, chunks: string[], + replyInThread = true, ): Promise { for (const chunkText of chunks) { const chunkCard = await renderMessageCard( @@ -675,7 +699,7 @@ export class FeishuMessageChannel data: { msg_type: "interactive", content: JSON.stringify(chunkCard), - reply_in_thread: true, + reply_in_thread: replyInThread, }, }); } diff --git a/src/kernel/init/init-flow.ts b/src/kernel/init/init-flow.ts index 0ab57fc..8a83fa1 100644 --- a/src/kernel/init/init-flow.ts +++ b/src/kernel/init/init-flow.ts @@ -345,11 +345,15 @@ export class InitFlow { if (!message.channel_id) return; const channel = this._feishuChannels.get(message.channel_id); if (!channel) return; - await channel.replyMessage(message.id, { - role: "assistant", - session_id: message.session_id, - content: [{ type: "text", text }], - }); + await channel.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }, + { streaming: false, replyInThread: false }, + ); } } diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index c86be5f..789a48b 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -153,11 +153,18 @@ class Kernel { } private _handleInboundMessage = async (message: UserMessage) => { - // Feishu substitutes @mentions as `@_user_N` placeholders in the text body; - // strip them so `@bot /bind foo bar` routes through the slash command path. - const text = extractTextContent(message) - .replace(/@_user_\d+/g, "") - .trim(); + // Feishu substitutes @mentions as `@_user_N` placeholders. Strip them + // ONLY on the first message of a session (the user @-summoning the bot + // to start a thread) so that `@bot /bind foo` routes through the slash + // command path. Subsequent messages inside the same thread keep their + // placeholders intact so real @-mentions of other users aren't mangled. + const isSessionStart = !this._sessionManager.existsSession( + message.session_id, + ); + const rawText = extractTextContent(message); + const text = isSessionStart + ? rawText.replace(/@_user_\d+/g, "").trim() + : rawText.trim(); // Handle /stop command (kernel-owned because it talks to TaskDispatcher) if (text === "/stop") { @@ -216,7 +223,11 @@ class Kernel { session_id: message.session_id, content: [{ type: "text", text: replyText }], }, - { channelId: message.channel_id, streaming: false }, + { + channelId: message.channel_id, + streaming: false, + replyInThread: false, + }, ); return true; }; @@ -235,7 +246,11 @@ class Kernel { session_id: sessionId, content: [{ type: "text", text: "✅ 任务已取消。" }], }, - { channelId: message.channel_id, streaming: false }, + { + channelId: message.channel_id, + streaming: false, + replyInThread: false, + }, ); } else { await this._messageGateway.replyMessage( @@ -245,7 +260,11 @@ class Kernel { session_id: sessionId, content: [{ type: "text", text: "ℹ️ 当前 session 没有正在执行的任务。" }], }, - { channelId: message.channel_id, streaming: false }, + { + channelId: message.channel_id, + streaming: false, + replyInThread: false, + }, ); } }; diff --git a/src/kernel/messaging/multi-channel-message-gateway.ts b/src/kernel/messaging/multi-channel-message-gateway.ts index fc553d3..59407d2 100644 --- a/src/kernel/messaging/multi-channel-message-gateway.ts +++ b/src/kernel/messaging/multi-channel-message-gateway.ts @@ -95,7 +95,11 @@ export class MultiChannelMessageGateway async replyMessage( messageId: string, message: Omit, - options?: { streaming?: boolean; channelId?: string }, + options?: { + streaming?: boolean; + channelId?: string; + replyInThread?: boolean; + }, ): Promise { const channel = this._resolveChannelFor(message.session_id, options?.channelId); const result = await channel.replyMessage(messageId, message, options); diff --git a/src/shared/messaging/message-channel.ts b/src/shared/messaging/message-channel.ts index 37f2cff..d6c7eec 100644 --- a/src/shared/messaging/message-channel.ts +++ b/src/shared/messaging/message-channel.ts @@ -64,7 +64,11 @@ export interface MessageChannel extends EventEmitter { * Reply to an existing message. * @param messageId - ID of the message to reply to. * @param message - The assistant message to send (without id). - * @param options - Optional settings (e.g. streaming mode). + * @param options - Optional settings. + * - `streaming`: card is part of a stream; skip text rendering until final. + * - `replyInThread`: Feishu-specific — default `true`. Set `false` for + * one-shot replies (e.g. slash commands) that should appear inline in + * the chat instead of opening a new topic. * @returns The sent message with id assigned. */ replyMessage( @@ -73,7 +77,7 @@ export interface MessageChannel extends EventEmitter { // eslint-disable-next-line no-unused-vars message: Omit, // eslint-disable-next-line no-unused-vars - options?: { streaming?: boolean }, + options?: { streaming?: boolean; replyInThread?: boolean }, ): Promise; /** diff --git a/src/shared/messaging/message-gateway.ts b/src/shared/messaging/message-gateway.ts index 3d7739b..99df94b 100644 --- a/src/shared/messaging/message-gateway.ts +++ b/src/shared/messaging/message-gateway.ts @@ -44,8 +44,12 @@ export interface MessageGateway extends EventEmitter { * Reply to an existing message. * @param messageId - ID of the message to reply to. * @param message - The assistant message to send (without id). - * @param options - Optional settings. `channelId` bypasses the session→channel - * DB lookup; use it when the session row may not exist yet. + * @param options - Optional settings. + * - `channelId` bypasses the session→channel DB lookup; use it when the + * session row may not exist yet. + * - `replyInThread` toggles whether the reply opens a new Feishu topic + * (default `true`, matches the session flow). Pass `false` for one-shot + * replies like slash commands so they show up inline in the chat list. * @returns The sent message with id assigned. */ replyMessage( @@ -54,7 +58,11 @@ export interface MessageGateway extends EventEmitter { // eslint-disable-next-line no-unused-vars message: Omit, // eslint-disable-next-line no-unused-vars - options?: { streaming?: boolean; channelId?: string }, + options?: { + streaming?: boolean; + channelId?: string; + replyInThread?: boolean; + }, ): Promise; /** From eac2bd13457f89667fde8b3c13a1890b1e10ed34 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 20:47:35 +0800 Subject: [PATCH 08/69] feat(codex): opt-in CODEX_HOME isolation for spawned Codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `agents.codex.isolate_host_env` (default `false`). When enabled, agentara redirects spawned Codex at `$AGENTARA_HOME/.codex/` via the `CODEX_HOME` env, keeping config / sessions / state / skills off the host's `~/.codex/`. Boot-loader symlinks `auth.json` from the host so the OAuth login is shared bi-directionally. Host `~/.codex/hooks.json` is intentionally not intercepted — Codex climbs cwd ancestors for `.codex/hooks.json` no matter what HOME or CODEX_HOME are set to. The right fix is to relocate the global hooks file out of `~/.codex/`, which is outside agentara's scope. --- src/boot-loader/boot-loader.ts | 52 ++++++++++++++++++++++ src/community/openai/codex-agent-runner.ts | 13 ++++++ src/shared/config/index.ts | 1 + src/shared/config/paths.ts | 10 +++++ src/shared/config/schema.ts | 18 ++++++++ 5 files changed, 94 insertions(+) diff --git a/src/boot-loader/boot-loader.ts b/src/boot-loader/boot-loader.ts index 7da35b3..0232537 100644 --- a/src/boot-loader/boot-loader.ts +++ b/src/boot-loader/boot-loader.ts @@ -88,6 +88,8 @@ agents: default: type: claude model: claude-sonnet-4-6 + codex: + isolate_host_env: false tasking: max_retries: 1 @@ -104,6 +106,22 @@ messaging: if (!existsSync(config.paths.data)) { mkdirSync(config.paths.data, { recursive: true }); } + + // Codex isolation (opt-in via `agents.codex.isolate_host_env`). + // When off, agentara-spawned Codex inherits the host `~/.codex` + // verbatim. When on, agentara points Codex at its own + // CODEX_HOME so config / sessions / state / skills stay + // separate from the host's; auth.json is symlinked so the + // OAuth login is shared. Nothing agentara does can prevent + // Codex from loading hooks from cwd ancestors under the real + // home — that problem lives in the host `~/.codex/hooks.json` + // placement itself. + if (config.agents.codex.isolate_host_env) { + if (!existsSync(config.paths.codex_home)) { + mkdirSync(config.paths.codex_home, { recursive: true }); + } + this._ensureCodexAuthSymlink(); + } } /** @@ -131,6 +149,40 @@ messaging: } } + /** + * Symlinks `$CODEX_HOME/auth.json` → `~/.codex/auth.json` so the + * isolated Codex home reuses the host login and OAuth token + * refresh stays bi-directional. No-ops if the link already exists + * or the host has no auth file yet. + */ + private _ensureCodexAuthSymlink(): void { + const hostAuth = join(config.paths.host_codex_home, "auth.json"); + const linkPath = join(config.paths.codex_home, "auth.json"); + try { + lstatSync(linkPath); + return; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") { + logger.warn({ err: e }, "Unexpected error checking Codex auth link"); + return; + } + } + if (!existsSync(hostAuth)) { + logger.info( + "Host ~/.codex/auth.json not found — skipping Codex auth symlink", + ); + return; + } + try { + symlinkSync(hostAuth, linkPath, "file"); + logger.info( + "Created symlink $AGENTARA_HOME/.codex/auth.json → ~/.codex/auth.json", + ); + } catch (err) { + logger.warn({ err }, "Failed to create Codex auth symlink"); + } + } + private async _igniteKernel(): Promise { const { kernel } = await import("@/kernel"); const logo = `\n▗▄▖ ▗▄▄▖▗▄▄▄▖▗▖ ▗▖▗▄▄▄▖▗▄▖ ▗▄▄▖ ▗▄▖ diff --git a/src/community/openai/codex-agent-runner.ts b/src/community/openai/codex-agent-runner.ts index 010a8d0..799c4c0 100644 --- a/src/community/openai/codex-agent-runner.ts +++ b/src/community/openai/codex-agent-runner.ts @@ -59,10 +59,23 @@ export class CodexAgentRunner implements AgentRunner { prompt: textContentOfUserMessage, }); + // Gated by `agents.codex.isolate_host_env` — off by default, + // in which case Codex inherits the host env verbatim. When on, + // CODEX_HOME is redirected so config / sessions / state stay + // separate from the host's `~/.codex/`. Host hook behavior is + // not touched here — Codex always climbs cwd ancestors for + // `.codex/hooks.json` regardless of CODEX_HOME, so keep your + // global hooks.json out of `~/.codex/` if you do not want it + // firing under agentara workspaces. + const isolationEnv = config.agents.codex.isolate_host_env + ? { CODEX_HOME: config.paths.codex_home } + : {}; + const proc = Bun.spawn(args, { cwd: options.cwd, env: { ...Bun.env, + ...isolationEnv, ...(options.envExtras ?? {}), }, stderr: "pipe", diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index 83134cc..d4b7bb6 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -12,6 +12,7 @@ export type { AppConfig, ChannelConfig, ChannelParams, + CodexConfig, MessagingConfig, PredefinedRepo, TaskingConfig, diff --git a/src/shared/config/paths.ts b/src/shared/config/paths.ts index 0fbb5ed..533b9f3 100644 --- a/src/shared/config/paths.ts +++ b/src/shared/config/paths.ts @@ -54,3 +54,13 @@ export const claude_home = join(home, ".claude"); export const skills = join(claude_home, "skills"); export const agents_home = join(home, ".agents"); + +/** + * Isolated `CODEX_HOME` for spawned Codex CLI processes. Keeps + * Codex's base config, sessions, state, and skills separate from + * the host's `~/.codex/`. Boot-loader seeds an `auth.json` symlink + * so the OAuth login is shared bi-directionally. Activated only + * when `agents.codex.isolate_host_env` is `true`. + */ +export const codex_home = join(home, ".codex"); +export const host_codex_home = join(user_home, ".codex"); diff --git a/src/shared/config/schema.ts b/src/shared/config/schema.ts index 41a5f45..dab005a 100644 --- a/src/shared/config/schema.ts +++ b/src/shared/config/schema.ts @@ -9,11 +9,29 @@ export const AgentConfig = z.object({ }); export interface AgentConfig extends z.infer {} +/** + * Codex CLI-specific runtime options. + */ +export const CodexConfig = z.object({ + /** + * When `true`, agentara points spawned Codex at its own + * `CODEX_HOME` so config / sessions / state / skills stay + * separate from the host's `~/.codex/`. Host `~/.codex/hooks.json` + * is still loaded by Codex via its cwd-ancestor climb — move + * that file out of `~/.codex/` yourself if you need it to skip + * agentara workspaces. Default `false` — agentara reuses the + * host setup. + */ + isolate_host_env: z.boolean().default(false), +}); +export interface CodexConfig extends z.infer {} + /** * Configuration for all agents. */ export const AgentsConfig = z.object({ default: AgentConfig, + codex: CodexConfig.default({ isolate_host_env: false }), }); export interface AgentsConfig extends z.infer {} From 974d31fa5dd8f0e07716a02a0afa4631464e6066 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 21:40:48 +0800 Subject: [PATCH 09/69] chore(scripts): add make restart; split dev/start server scripts - `dev:server` now runs with `bun --watch` for auto-reload during development - new `start:server` (no watch) used by `make up` for stable background runs - `make restart` chains down + up to avoid manual two-step - fix `scripts/down.sh` to work on macOS bash 3.2 (drop `declare -A`) --- Makefile | 4 ++++ package.json | 3 ++- scripts/down.sh | 13 +++++++++---- scripts/up.sh | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 37aa835..7ab13b6 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,7 @@ up: down: @bash scripts/down.sh + +restart: + @bash scripts/down.sh + @bash scripts/up.sh diff --git a/package.json b/package.json index 5061fca..d6478ca 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "check:types": "tsc --noEmit", "clean:data": "rm -rf user-home/data && rm -rf user-home/sessions", "dev": "concurrently \"bun run dev:server\" \"bun run dev:web\"", - "dev:server": "bun run index.ts", + "dev:server": "bun --watch run index.ts", "dev:web": "cd web && bun run dev", + "start:server": "bun run index.ts", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "prepare": "husky" diff --git a/scripts/down.sh b/scripts/down.sh index 9877b02..295c706 100755 --- a/scripts/down.sh +++ b/scripts/down.sh @@ -8,15 +8,20 @@ GRACEFUL_WAIT_TICKS=10 # 10 × 0.5s = 5 seconds EXIT_CODE=0 # Expected command patterns per process name -declare -A EXPECTED_CMD -EXPECTED_CMD[server]="bun run dev:server" -EXPECTED_CMD[web]="bun run dev:web" +expected_cmd_for() { + case "$1" in + server) echo "bun run start:server" ;; + web) echo "bun run dev:web" ;; + *) echo "" ;; + esac +} # Verify PID belongs to the expected process is_our_process() { local pid="$1" local name="$2" - local expected="${EXPECTED_CMD[$name]:-}" + local expected + expected="$(expected_cmd_for "$name")" if [ -z "$expected" ]; then return 0 # no pattern to match, assume ours diff --git a/scripts/up.sh b/scripts/up.sh index 57cd775..00445ea 100755 --- a/scripts/up.sh +++ b/scripts/up.sh @@ -33,7 +33,7 @@ echo "Starting Agentara in the background..." # Start backend server cd "$PROJECT_DIR" -nohup bun run dev:server > "$LOG_DIR/server.log" 2>&1 & +nohup bun run start:server > "$LOG_DIR/server.log" 2>&1 & SERVER_PID=$! sleep 1 if ! kill -0 "$SERVER_PID" 2>/dev/null; then From b26c0c24c4410a85efafd48c04fdc33c0acac710 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 16 Apr 2026 21:42:27 +0800 Subject: [PATCH 10/69] feat(setup): rename /init to /setup; source catalog from REPOS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename command, files (init-* → setup-*), design doc, card/form field names, and doc cross-references - move repo catalog out of `config.yaml` into `$AGENTARA_HOME/REPOS.md` so the same file doubles as agent-readable context via CLAUDE.md's native `@REPOS.md` import - add `loadPredefinedRepos()` Markdown parser (H2 section per repo, `- git_url: …` bullet, first prose line = description) - boot-loader seeds a starter `REPOS.md` on first run and idempotently appends `@REPOS.md` to CLAUDE.md when missing - catalog is re-read on every `/setup` invocation; edits take effect without kernel restart --- docs/designs/group-workspace-binding.md | 8 +- docs/designs/init-flow.md | 112 --------------- docs/designs/setup-flow.md | 127 ++++++++++++++++++ src/boot-loader/boot-loader.ts | 67 ++++++++- .../feishu/messaging/message-channel.ts | 2 +- .../messaging/types/interactive/elements.ts | 4 +- src/kernel/commands/handlers.ts | 2 +- src/kernel/kernel.ts | 22 +-- src/kernel/sessioning/data/schema.ts | 2 +- .../init-card.ts => setup/setup-card.ts} | 26 ++-- .../init-flow.ts => setup/setup-flow.ts} | 75 ++++++----- src/shared/config/index.ts | 9 +- src/shared/config/paths.ts | 2 + src/shared/config/predefined-repos.ts | 110 +++++++++++++++ src/shared/config/schema.ts | 18 +-- src/shared/messaging/message-channel.ts | 2 +- .../workspaces/types/group-workspace.ts | 2 +- 17 files changed, 384 insertions(+), 206 deletions(-) delete mode 100644 docs/designs/init-flow.md create mode 100644 docs/designs/setup-flow.md rename src/kernel/{init/init-card.ts => setup/setup-card.ts} (88%) rename src/kernel/{init/init-flow.ts => setup/setup-flow.ts} (84%) create mode 100644 src/shared/config/predefined-repos.ts diff --git a/docs/designs/group-workspace-binding.md b/docs/designs/group-workspace-binding.md index d622fae..b163f27 100644 --- a/docs/designs/group-workspace-binding.md +++ b/docs/designs/group-workspace-binding.md @@ -15,7 +15,7 @@ Enable one agentara bot to serve multiple Feishu groups. Each group binds to its - Per-topic workspace override (binding lives at group level only) - Multiple simultaneously-active repos inside one workspace -- Rich card UI for `/init` in v1 (text commands first, card in v2) +- Rich card UI for `/setup` in v1 (text commands first, card in v2) ## Data Model @@ -76,7 +76,7 @@ Parsed in `Kernel._handleInboundMessage` (`src/kernel/kernel.ts:114`) before `Ta | Command | Scope | Effect | |---------|-------|--------| -| `/init` | group | Reply with an interactive card. User picks repo + branch; card submit writes group binding. v1 may fall back to `/bind`. | +| `/setup` | group | Reply with an interactive card. User picks repo + branch; card submit writes group binding. v1 may fall back to `/bind`. | | `/bind ` | group | Upsert `group_workspaces` row, set `active_repo` + `active_branch`. Rejects if `` not in workspace (suggests `/clone`). | | `/unbind` | group | Delete `group_workspaces` row. | | `/status` | group + topic | Show group binding, list cloned repos, show current topic's `session_id`. | @@ -88,7 +88,7 @@ Parsed in `Kernel._handleInboundMessage` (`src/kernel/kernel.ts:114`) before `Ta Already-implemented reference: `/stop` (`src/kernel/kernel.ts:118`). Extend the same if-chain into a command table. -Card interaction for `/init` should mirror the streaming card pattern in `remote_claude/lark_client/shared_memory_poller.py` + interactive element handling in Feishu card-action callbacks. Deferred to v2. +Card interaction for `/setup` should mirror the streaming card pattern in `remote_claude/lark_client/shared_memory_poller.py` + interactive element handling in Feishu card-action callbacks. Deferred to v2. ## Resolution Flow @@ -202,7 +202,7 @@ No breaking change for users with existing single-chat config — a missing `cha 6. Slash command parser + core handlers (`/bind`, `/unbind`, `/status`, `/clone`, `/checkout`, `/ls`) 7. `/new`, `/agent` — session lifecycle commands 8. Default workspace bootstrap -9. v2: `/init` Feishu card interaction +9. v2: `/setup` Feishu card interaction ## Open Questions diff --git a/docs/designs/init-flow.md b/docs/designs/init-flow.md deleted file mode 100644 index de3f7bf..0000000 --- a/docs/designs/init-flow.md +++ /dev/null @@ -1,112 +0,0 @@ -# InitFlow Design - -Interactive `/init` command: presents a Feishu card with pre-defined repositories, collects user's repo+branch selections via a form submit, then clones, checks out, and binds the group workspace in one shot. - -Specialized, **not** generic — no broader "interactive card framework". Only `/init` triggers this path. - -## Dependencies - -- `src/shared/config/schema.ts` — new `PredefinedRepo` and optional top-level `predefined_repos` -- `src/community/feishu/messaging/types/interactive/` — new element types (form, checker, input, button, column_set, select_static) -- `src/community/feishu/messaging/message-channel.ts` — subscribe `card.action.trigger`, emit `card:action`, expose `sendRawCard` and `updateRawCard` -- `src/shared/messaging/message-channel.ts` + `message-gateway.ts` — add `"card:action"` event type -- `src/kernel/workspaces/store.ts` — reuse `upsertBinding`, `resolve` -- `src/kernel/kernel.ts` — special-case `/init` like `/stop`; subscribe `card:action` - -## Config - -```yaml -predefined_repos: - - name: agentara - description: 7x24h personal assistant - git_url: https://github.com/xluos/agentara.git -``` - -`predefined_repos` is optional. When unset or empty, `/init` replies with an error asking the operator to configure the catalog. - -## Card Shape - -Single Feishu Card 2.0 form with one row per predefined repo. Each row is a `column_set` with: -- `checker` — `name: repo_`, label is the repo name -- `input` — `name: branch_`, `placeholder: master`, no default value (empty submission means master) -- `markdown` — description text - -Below the rows: a `select_static` for primary-repo selection (`name: primary_repo`, options built from the catalog), always shown. - -Footer: `button` with `action_type: form_submit`, `behaviors.callback.value = { action: "init_submit", init_id }`. - -## Events - -`FeishuMessageChannel` adds a third event handler `card.action.trigger`. It normalizes the SDK payload into a minimal shape: - -```ts -interface CardActionPayload { - message_id: string; - action_name: string; // action.value.action - init_id?: string; // action.value.init_id - operator_open_id: string; // who clicked - form_value: Record;// input/checker values on form_submit - chat_id: string; -} -``` - -Emits as `card:action` on the channel; `MultiChannelMessageGateway` re-emits; kernel dispatches by `action_name` (only `"init_submit"` recognized). - -## API - -### `InitFlow` (src/kernel/init/init-flow.ts) - -```ts -class InitFlow { - constructor(deps: { - workspaceStore: GroupWorkspaceStore; - feishuChannels: Map; // by channel_id - logger: Logger; - }); - - // Entry: called from kernel._handleInboundMessage when text === "/init" - start(message: UserMessage): Promise; - - // Entry: called from kernel card:action listener when action_name === "init_submit" - handleSubmit(payload: CardActionPayload): Promise; -} -``` - -### Pending state - -In-memory `Map` on the `InitFlow` instance: - -```ts -interface PendingInit { - chat_id: string; - initiator_open_id: string; - catalog_snapshot: PredefinedRepo[]; // frozen at card render time - created_at: number; -} -``` - -Lost on kernel restart — acceptable. On submit for an unknown `message_id`, update the card in place with an "expired, please re-run /init" message. - -## Error Handling - -- `/init` when `predefined_repos` is unset/empty → text reply: ask operator to configure. -- `/init` when the group is already bound (`upsertBinding` already present) → text reply: `已绑定 @,请先 /unbind`. -- `/init` outside Feishu (`!message.chat_id`) → text reply: `/init 仅在飞书群内可用`. -- Submit by non-initiator → update card: `这不是你的表单`. -- No repos selected on submit → update card: `未选择任何仓库`. -- Per-repo clone failure → keep successful clones, record the failed entry; show per-repo status in result card. -- Per-repo checkout failure → keep the clone, leave `active_branch` unset if that repo becomes primary; warn on the card. -- All throws from the submit path → update card with `❌ 初始化失败: `. - -No silent fallback. No graceful retry. Every failure surfaces on the card. - -## Concurrency / Constraints - -- Single-writer to pending state: all access through one `InitFlow` instance on the kernel. -- One pending init per `chat_id` — a second `/init` while another is pending replaces the first card's pending entry (in-memory map is keyed by `message_id`, so earlier card becomes unreachable and effectively expired). -- Clone operations run sequentially within one submit, keeping output deterministic and avoiding concurrent writes to the same workspace directory. -- Primary-repo select always offers the full catalog (not just checked rows). If the primary isn't among the checked rows on submit, treat submit as invalid and update card. - -## Result Card - -After submit completes (success or partial), the original card is replaced with a result card (via `updateRawCard`) showing per-repo status (`✅ cloned` / `⚠️ checkout failed` / `❌ clone failed`), the final binding, and next-step hints. diff --git a/docs/designs/setup-flow.md b/docs/designs/setup-flow.md new file mode 100644 index 0000000..9b79023 --- /dev/null +++ b/docs/designs/setup-flow.md @@ -0,0 +1,127 @@ +# SetupFlow Design + +Interactive `/setup` command: presents a Feishu card with pre-defined repositories (sourced from `$AGENTARA_HOME/REPOS.md`), collects user's repo+branch selections via a form submit, then clones, checks out, and binds the group workspace in one shot. + +Specialized, **not** generic — no broader "interactive card framework". Only `/setup` triggers this path. Day-to-day binding uses `/bind `. + +## Dependencies + +- `src/shared/config/predefined-repos.ts` — `PredefinedRepo` Zod schema + `loadPredefinedRepos()` Markdown parser +- `src/community/feishu/messaging/types/interactive/` — element types (form, checker, input, button, column_set, select_static) +- `src/community/feishu/messaging/message-channel.ts` — subscribes `card.action.trigger`, emits `card:action`, exposes `sendRawCard` and `updateRawCard` +- `src/shared/messaging/message-channel.ts` + `message-gateway.ts` — `"card:action"` event type +- `src/kernel/workspaces/store.ts` — reuses `upsertBinding`, `resolve` +- `src/kernel/kernel.ts` — special-cases `/setup` like `/stop`; subscribes `card:action` +- `src/boot-loader/boot-loader.ts` — seeds an empty `REPOS.md` on first boot and ensures CLAUDE.md references it + +## Source — `$AGENTARA_HOME/REPOS.md` + +The repo catalog lives in a Markdown file instead of `config.yaml` so the +same file can double as agent-readable context. CLAUDE.md imports it via +Claude Code's native `@REPOS.md` syntax; the boot-loader appends that +reference automatically if missing. + +Each `## ` section defines one repo. The first `- git_url: ` +bullet supplies the clone URL; the first non-bullet prose line becomes +the one-line description rendered on the card. The rest of the section +body is free-form prose for the agent — and the agent is expected to +update those descriptions over time as it learns more about each repo. + +```markdown +## agentara + +- git_url: https://github.com/magiccube/agentara.git + +Bun + TypeScript personal assistant. Useful when a group is discussing +the kernel, session/task orchestration, or Feishu bot integration. +``` + +When `REPOS.md` is missing or yields zero parseable entries, `/setup` +replies with an error pointing the operator at the file. + +`loadPredefinedRepos()` is called on every `/setup` invocation — no +caching — so edits to the Markdown take effect immediately. + +## Card Shape + +Single Feishu Card 2.0 form with one row per predefined repo. Each row is a `column_set` with: +- `checker` — `name: repo_`, label is `` (plus `— ` when present) +- `input` — `name: branch_`, `placeholder: master`, no default value (empty submission means master) + +Below the rows: a `select_static` for primary-repo selection (`name: primary_repo`, options built from the catalog), always shown. + +Footer: `button` with `name: "setup_submit"` and `action_type: form_submit`. + +## Events + +`FeishuMessageChannel` handles `card.action.trigger` and normalizes the SDK payload into a minimal shape: + +```ts +interface CardActionPayload { + message_id: string; + action_name: string; // button/element name (e.g. "setup_submit") + operator_open_id: string; // who clicked + form_value: Record;// input/checker values on form_submit + chat_id: string; +} +``` + +Emitted as `card:action` on the channel; `MultiChannelMessageGateway` re-emits; kernel dispatches by `action_name` (only `"setup_submit"` recognized). + +## API + +### `SetupFlow` (`src/kernel/setup/setup-flow.ts`) + +```ts +class SetupFlow { + constructor(deps: { + workspaceStore: GroupWorkspaceStore; + feishuChannels: Map; // by channel_id + }); + + // Entry: called from kernel._handleInboundMessage when text === "/setup" + start(message: UserMessage): Promise; + + // Entry: called from kernel card:action listener when action_name === "setup_submit" + handleSubmit(payload: CardActionPayload): Promise; +} +``` + +### Pending state + +In-memory `Map` on the `SetupFlow` instance: + +```ts +interface PendingSetup { + chat_id: string; + initiator_open_id: string; + catalog_snapshot: PredefinedRepo[]; // frozen at card render time + created_at: number; +} +``` + +Lost on kernel restart — acceptable. On submit for an unknown `message_id`, the card is updated in place with an "expired, please re-run /setup" message. + +## Error Handling + +- `/setup` when `REPOS.md` is missing or empty → text reply: ask operator to fill in `REPOS.md`. +- `/setup` when the group is already bound (`upsertBinding` already present) → text reply: `已绑定 @,请先 /unbind`. +- `/setup` outside Feishu (`!message.chat_id`) → text reply: `/setup 仅在飞书群内可用`. +- Submit by non-initiator → update card: `这不是你的表单`. +- No repos selected on submit → update card: `未选择任何仓库`. +- Per-repo clone failure → keep successful clones, record the failed entry; show per-repo status in result card. +- Per-repo checkout failure → keep the clone, bind to whatever the clone landed on; warn on the card. +- All throws from the submit path → update card with `❌ 初始化失败: `. + +No silent fallback. No graceful retry. Every failure surfaces on the card. + +## Concurrency / Constraints + +- Single-writer to pending state: all access through one `SetupFlow` instance on the kernel. +- One pending setup per `chat_id` — a second `/setup` while another is pending replaces the first card's pending entry (in-memory map is keyed by `message_id`, so the earlier card becomes unreachable and effectively expired). +- Clone operations run sequentially within one submit, keeping output deterministic and avoiding concurrent writes to the same workspace directory. +- Primary-repo select always offers the full catalog (not just checked rows). If the primary isn't among the checked rows on submit, the handler falls back to the first checked row instead of rejecting. + +## Result Card + +After submit completes (success or partial), the original card is replaced with a result card (via `updateRawCard`) showing per-repo status (`✅ cloned` / `⚠️ checkout failed` / `❌ clone failed`), the final binding, and next-step hints. diff --git a/src/boot-loader/boot-loader.ts b/src/boot-loader/boot-loader.ts index 0232537..2f709b4 100644 --- a/src/boot-loader/boot-loader.ts +++ b/src/boot-loader/boot-loader.ts @@ -1,9 +1,11 @@ import { execSync } from "node:child_process"; import { + appendFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, + readFileSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -63,12 +65,21 @@ class BootLoader { join(config.paths.claude_home, "settings.json"), ); } - if (!existsSync(join(config.paths.home, "CLAUDE.md"))) { + const claudeMdPath = join(config.paths.home, "CLAUDE.md"); + if (!existsSync(claudeMdPath)) { await downloadFile( "https://raw.githubusercontent.com/magiccube/agentara/main/user-home/CLAUDE.md", - join(config.paths.home, "CLAUDE.md"), + claudeMdPath, ); } + if (!existsSync(config.paths.repos_md)) { + writeFileSync(config.paths.repos_md, REPOS_MD_TEMPLATE, "utf-8"); + logger.info("Seeded $AGENTARA_HOME/REPOS.md with a starter template."); + } + // Keep CLAUDE.md pointed at REPOS.md so the agent sees the catalog + + // descriptions in context. Idempotent: only appends when the reference + // is missing, so user edits to CLAUDE.md are preserved. + this._ensureClaudeMdReferencesRepos(claudeMdPath); if (!existsSync(config.paths.skills)) { await downloadSkills(); } @@ -124,6 +135,29 @@ messaging: } } + /** + * Append `@REPOS.md` to `$AGENTARA_HOME/CLAUDE.md` if it isn't already + * referenced. The reference lets Claude Code inline the repo catalog + + * descriptions into the agent's context via its native `@file` import. + * User edits to CLAUDE.md are preserved — we only append when missing. + */ + private _ensureClaudeMdReferencesRepos(claudeMdPath: string): void { + try { + if (!existsSync(claudeMdPath)) return; + const body = readFileSync(claudeMdPath, "utf-8"); + if (/^\s*@REPOS\.md\s*$/m.test(body)) return; + const needsNewline = body.length > 0 && !body.endsWith("\n"); + appendFileSync( + claudeMdPath, + `${needsNewline ? "\n" : ""}\n@REPOS.md\n`, + "utf-8", + ); + logger.info("Added `@REPOS.md` reference to CLAUDE.md."); + } catch (err) { + logger.warn({ err }, "Failed to ensure CLAUDE.md references REPOS.md"); + } + } + /** * Creates a symlink at `.agents/skills` → `.claude/skills` so the Codex * CLI can discover skills via its native directory. The link is only @@ -217,4 +251,33 @@ async function downloadSkills(): Promise { execSync(`rm -rf ${tempDir}`); } +const REPOS_MD_TEMPLATE = `# Predefined Repos + + + + +`; + export const bootLoader = new BootLoader(); diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 143ff25..29be917 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -172,7 +172,7 @@ export class FeishuMessageChannel /** * Send a raw Feishu interactive card to a chat. Escape hatch used by - * commands that render custom cards (e.g. `/init`) outside the normal + * commands that render custom cards (e.g. `/setup`) outside the normal * AssistantMessage pipeline. Returns the posted message's id so the caller * can correlate later card actions / updates. * diff --git a/src/community/feishu/messaging/types/interactive/elements.ts b/src/community/feishu/messaging/types/interactive/elements.ts index 7cee136..90a816f 100644 --- a/src/community/feishu/messaging/types/interactive/elements.ts +++ b/src/community/feishu/messaging/types/interactive/elements.ts @@ -77,8 +77,8 @@ export interface CollapsiblePanel extends BaseContainer<"collapsible_panel"> { } /** - * Generic callback value on an interactive element. Our `/init` flow uses - * `{ action: string; init_id: string; ... }`, but we keep the shape open so + * Generic callback value on an interactive element. Our `/setup` flow uses + * `{ action: string; setup_id: string; ... }`, but we keep the shape open so * future interactive commands can attach whatever discriminator they need. * The server receives this verbatim on `card.action.trigger`. */ diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 013d275..4f5dac1 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -211,7 +211,7 @@ export const helpHandler: CommandHandler = { ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), "- /help — 显示本消息", "- /stop — 取消当前 session 正在执行的任务", - "- /init — 打开交互卡片,从预定义仓库目录中批量克隆并绑定", + "- /setup — 打开交互卡片,从 REPOS.md 里的仓库目录中批量克隆并绑定", ].join("\n"); }, }; diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 789a48b..9577665 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -14,10 +14,10 @@ import { import { HonoServer } from "../server"; import { CommandRegistry, parseCommand } from "./commands"; -import { InitFlow } from "./init/init-flow"; import { MultiChannelMessageGateway } from "./messaging"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; +import { SetupFlow } from "./setup/setup-flow"; import { TaskDispatcher } from "./tasking"; import * as taskingSchema from "./tasking/data"; import { GroupWorkspaceStore } from "./workspaces"; @@ -36,7 +36,7 @@ class Kernel { private _workspaceStore!: GroupWorkspaceStore; private _commandRegistry!: CommandRegistry; private _feishuChannels = new Map(); - private _initFlow!: InitFlow; + private _setupFlow!: SetupFlow; constructor() { this._initDatabase(); @@ -45,7 +45,7 @@ class Kernel { this._initCommandRegistry(); this._initTaskDispatcher(); this._initMessageGateway(); - this._initInitFlow(); + this._initSetupFlow(); this._initServer(); } @@ -135,8 +135,8 @@ class Kernel { this._messageGateway.on("card:action", this._handleCardAction); } - private _initInitFlow(): void { - this._initFlow = new InitFlow({ + private _initSetupFlow(): void { + this._setupFlow = new SetupFlow({ workspaceStore: this._workspaceStore, feishuChannels: this._feishuChannels, }); @@ -172,10 +172,10 @@ class Kernel { return; } - // Handle /init command (kernel-owned — renders an interactive card and + // Handle /setup command (kernel-owned — renders an interactive card and // awaits a card:action callback rather than returning a plain text reply). - if (text === "/init") { - await this._initFlow.start(message); + if (text === "/setup") { + await this._setupFlow.start(message); return; } @@ -285,11 +285,11 @@ class Kernel { /** * Route card-action callbacks by the `action_name` discriminator. Right now - * only `/init` produces callbacks; unknown actions are logged and dropped. + * only `/setup` produces callbacks; unknown actions are logged and dropped. */ private _handleCardAction = async (payload: CardActionPayload) => { - if (payload.action_name === "init_submit") { - await this._initFlow.handleSubmit(payload); + if (payload.action_name === "setup_submit") { + await this._setupFlow.handleSubmit(payload); return; } this._logger.warn( diff --git a/src/kernel/sessioning/data/schema.ts b/src/kernel/sessioning/data/schema.ts index 1a70e33..0c55704 100644 --- a/src/kernel/sessioning/data/schema.ts +++ b/src/kernel/sessioning/data/schema.ts @@ -33,7 +33,7 @@ export const sessions = sqliteTable("sessions", { /** * Persisted group↔workspace bindings. One row per Feishu group that has been - * bound via `/bind` or `/init`. Absence of a row means the group is unbound + * bound via `/bind` or `/setup`. Absence of a row means the group is unbound * and falls back to the default workspace. * * workspace_path is always `$AGENTARA_HOME/workspaces//`; stored to diff --git a/src/kernel/init/init-card.ts b/src/kernel/setup/setup-card.ts similarity index 88% rename from src/kernel/init/init-card.ts rename to src/kernel/setup/setup-card.ts index 14ec7d0..6ed57d0 100644 --- a/src/kernel/init/init-card.ts +++ b/src/kernel/setup/setup-card.ts @@ -16,27 +16,27 @@ import type { * Field naming convention used by both the card renderer and the submit * handler. Keep them in one place so the two sides cannot drift apart. */ -export const INIT_FIELD = { +export const SETUP_FIELD = { repoChecker: (name: string) => `repo_${name}`, branchInput: (name: string) => `branch_${name}`, primaryRepo: "primary_repo", } as const; /** - * Build the interactive `/init` card. + * Build the interactive `/setup` card. * * Layout: * - Header: prompt text * - Form body: one row per predefined repo (checker + branch input + description) * - Primary-repo selector (always shown; default = first repo in catalog) * - Submit button ("初始化") with `action_type: "form_submit"`. On submit the - * server receives `action.name = "init_submit"` and `action.form_value` + * server receives `action.name = "setup_submit"` and `action.form_value` * carries the checker + input + select values. * * Card-to-pending correlation happens on the kernel side via `message_id`, - * so the card itself carries no init_id. + * so the card itself carries no setup_id. */ -export function buildInitCard(catalog: PredefinedRepo[]): Card { +export function buildSetupCard(catalog: PredefinedRepo[]): Card { const formElements: Element[] = []; for (const repo of catalog) { @@ -48,7 +48,7 @@ export function buildInitCard(catalog: PredefinedRepo[]): Card { const form: FormElement = { tag: "form", - name: "init_form", + name: "setup_form", elements: formElements, }; @@ -85,13 +85,13 @@ function _buildRepoRow(repo: PredefinedRepo): ColumnSetElement { : repo.name; const checker: CheckerElement = { tag: "checker", - name: INIT_FIELD.repoChecker(repo.name), + name: SETUP_FIELD.repoChecker(repo.name), text: { tag: "plain_text", content: label }, checked: false, }; const branchInput: InputElement = { tag: "input", - name: INIT_FIELD.branchInput(repo.name), + name: SETUP_FIELD.branchInput(repo.name), placeholder: { tag: "plain_text", content: "master" }, width: "fill", }; @@ -110,7 +110,7 @@ function _buildRepoRow(repo: PredefinedRepo): ColumnSetElement { function _buildPrimarySelect(catalog: PredefinedRepo[]): SelectStaticElement { return { tag: "select_static", - name: INIT_FIELD.primaryRepo, + name: SETUP_FIELD.primaryRepo, placeholder: { tag: "plain_text", content: "选择主仓库(默认第一个)" }, initial_option: catalog[0]?.name, options: catalog.map((r) => ({ @@ -128,12 +128,12 @@ function _buildSubmitButton(): ButtonElement { // `callback` behavior makes the button look like a plain callback button // instead, producing "there is no submit button in the form container". // - // The init flow correlates the submit event by `message_id` (we keep + // The setup flow correlates the submit event by `message_id` (we keep // pending state keyed by the card's message id), so the button does not - // need to carry init_id itself. + // need to carry setup_id itself. return { tag: "button", - name: "init_submit", + name: "setup_submit", text: { tag: "plain_text", content: "✅ 初始化" }, type: "primary", action_type: "form_submit", @@ -144,7 +144,7 @@ function _buildSubmitButton(): ButtonElement { * Result card rendered after the submit handler finishes. Replaces the * original card in place via `updateRawCard`. */ -export function buildInitResultCard( +export function buildSetupResultCard( summary: string, perRepoLines: string[], ): Card { diff --git a/src/kernel/init/init-flow.ts b/src/kernel/setup/setup-flow.ts similarity index 84% rename from src/kernel/init/init-flow.ts rename to src/kernel/setup/setup-flow.ts index 8a83fa1..a1532e9 100644 --- a/src/kernel/init/init-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -5,6 +5,7 @@ import type { Logger } from "@/shared"; import { config, createLogger, + loadPredefinedRepos, type CardActionPayload, type PredefinedRepo, type UserMessage, @@ -13,14 +14,18 @@ import { import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; import type { GroupWorkspaceStore } from "../workspaces"; -import { buildInitCard, buildInitResultCard, INIT_FIELD } from "./init-card"; +import { + buildSetupCard, + buildSetupResultCard, + SETUP_FIELD, +} from "./setup-card"; /** - * In-memory pending state for a `/init` card that has been sent but not yet - * submitted. Dropped on kernel restart — expired cards surface a clear error - * back to the user instead of being silently honored. + * In-memory pending state for a `/setup` card that has been sent but not + * yet submitted. Dropped on kernel restart — expired cards surface a clear + * error back to the user instead of being silently honored. */ -interface PendingInit { +interface PendingSetup { chat_id: string; initiator_open_id: string; catalog_snapshot: PredefinedRepo[]; @@ -42,25 +47,25 @@ interface RepoResult { } /** - * Stateful orchestrator for the `/init` interactive flow. + * Stateful orchestrator for the `/setup` interactive flow. * * Lifecycle per invocation: - * 1. `start(message)` renders the card and remembers the catalog snapshot - * keyed by the outbound message id. - * 2. The kernel routes a `card:action` with `action_name === "init_submit"` + * 1. `start(message)` loads the catalog from `REPOS.md`, renders the card, + * and remembers the catalog snapshot keyed by the outbound message id. + * 2. The kernel routes a `card:action` with `action_name === "setup_submit"` * to `handleSubmit(payload)`. * 3. The handler clones the selected repos, checks out the requested branches, * upserts the group binding, and replaces the original card with a result * card via `updateRawCard`. * * Single-writer: all pending-state access stays on this instance. One pending - * init per chat is implicit — issuing `/init` again replaces the key. + * setup per chat is implicit — issuing `/setup` again replaces the key. */ -export class InitFlow { - private readonly _logger: Logger = createLogger("init-flow"); +export class SetupFlow { + private readonly _logger: Logger = createLogger("setup-flow"); private readonly _workspaceStore: GroupWorkspaceStore; private readonly _feishuChannels: Map; - private readonly _pending = new Map(); + private readonly _pending = new Map(); constructor(deps: { workspaceStore: GroupWorkspaceStore; @@ -72,20 +77,20 @@ export class InitFlow { /** * Entry point invoked from `kernel._handleInboundMessage` when the inbound - * text is `/init`. Sends back either a plain-text error (no catalog, already - * bound, ...) or the interactive card. + * text is `/setup`. Sends back either a plain-text error (no catalog, + * already bound, ...) or the interactive card. */ async start(message: UserMessage): Promise { const chatId = message.chat_id; if (!chatId || !message.channel_id) { - await this._replyText(message, "❌ /init 仅在飞书群内可用。"); + await this._replyText(message, "❌ /setup 仅在飞书群内可用。"); return; } - const catalog = config.predefined_repos; + const catalog = loadPredefinedRepos(); if (catalog.length === 0) { await this._replyText( message, - "❌ 未配置 `predefined_repos`,请先在 `config.yaml` 里加上仓库目录。", + "❌ 仓库目录为空,请在 `$AGENTARA_HOME/REPOS.md` 里添加仓库条目(参考文件顶部的示例)。", ); return; } @@ -105,7 +110,7 @@ export class InitFlow { return; } - const card = buildInitCard(catalog); + const card = buildSetupCard(catalog); const cardMessageId = await channel.sendRawCard(chatId, card, { replyTo: message.id, }); @@ -117,15 +122,15 @@ export class InitFlow { }); this._logger.info( { chat_id: chatId, card_message_id: cardMessageId }, - "init card sent", + "setup card sent", ); } /** * Entry point invoked from the kernel's `card:action` listener when the - * payload's `action_name === "init_submit"`. Looks up the pending state by - * `payload.message_id` and either rejects the submission (expired, wrong - * user, invalid selection) or runs the clone+bind sequence. + * payload's `action_name === "setup_submit"`. Looks up the pending state + * by `payload.message_id` and either rejects the submission (expired, + * wrong user, invalid selection) or runs the clone+bind sequence. */ async handleSubmit(payload: CardActionPayload): Promise { const channel = this._feishuChannels.get(payload.channel_id); @@ -141,8 +146,8 @@ export class InitFlow { await this._tryUpdateCard( channel, payload.message_id, - buildInitResultCard( - "⚠️ 这张卡片已失效,请重新发送 `/init`。", + buildSetupResultCard( + "⚠️ 这张卡片已失效,请重新发送 `/setup`。", [], ), "expired", @@ -156,7 +161,7 @@ export class InitFlow { await this._tryUpdateCard( channel, payload.message_id, - buildInitResultCard("🚫 这不是你的表单。", []), + buildSetupResultCard("🚫 这不是你的表单。", []), "non-initiator", ); return; @@ -172,15 +177,15 @@ export class InitFlow { await this._tryUpdateCard( channel, payload.message_id, - buildInitResultCard("⚠️ 未选择任何仓库,请重新发送 `/init`。", []), + buildSetupResultCard("⚠️ 未选择任何仓库,请重新发送 `/setup`。", []), "empty-selection", ); return; } const rawPrimary = - typeof payload.form_value[INIT_FIELD.primaryRepo] === "string" - ? (payload.form_value[INIT_FIELD.primaryRepo] as string) + typeof payload.form_value[SETUP_FIELD.primaryRepo] === "string" + ? (payload.form_value[SETUP_FIELD.primaryRepo] as string) : ""; // `selections` is guaranteed non-empty by the earlier length-check return. const firstSel = selections[0]!; @@ -194,7 +199,7 @@ export class InitFlow { await this._tryUpdateCard( channel, payload.message_id, - buildInitResultCard( + buildSetupResultCard( `⏳ 正在初始化 \`${selections.map((s) => s.name).join("、")}\`…`, selections.map( (s) => `- \`${s.name}\` @ \`${s.branch}\``, @@ -236,7 +241,7 @@ export class InitFlow { await this._tryUpdateCard( channel, payload.message_id, - buildInitResultCard(summary, lines), + buildSetupResultCard(summary, lines), "final-result", ); this._logger.info( @@ -245,7 +250,7 @@ export class InitFlow { primary, results: results.map((r) => ({ name: r.name, status: r.status })), }, - "init submit completed", + "setup submit completed", ); } @@ -257,7 +262,7 @@ export class InitFlow { private async _tryUpdateCard( channel: FeishuMessageChannel, messageId: string, - card: ReturnType, + card: ReturnType, stage: string, ): Promise { try { @@ -278,9 +283,9 @@ export class InitFlow { const out: Array<{ repo: PredefinedRepo; name: string; branch: string }> = []; for (const repo of catalog) { - const rawChecked = formValue[INIT_FIELD.repoChecker(repo.name)]; + const rawChecked = formValue[SETUP_FIELD.repoChecker(repo.name)]; if (!_isTruthyChecker(rawChecked)) continue; - const rawBranch = formValue[INIT_FIELD.branchInput(repo.name)]; + const rawBranch = formValue[SETUP_FIELD.branchInput(repo.name)]; const branch = typeof rawBranch === "string" && rawBranch.trim() ? rawBranch.trim() diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index d4b7bb6..944c3ea 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -14,10 +14,11 @@ export type { ChannelParams, CodexConfig, MessagingConfig, - PredefinedRepo, TaskingConfig, } from "./schema"; +export { loadPredefinedRepos, PredefinedRepo } from "./predefined-repos"; + /** * Combined configuration interface including both YAML-loaded app config and paths. */ @@ -90,11 +91,5 @@ export const config = { } return _appConfig.messaging; }, - get predefined_repos() { - if (!_appConfig) { - return []; - } - return _appConfig.predefined_repos; - }, paths, }; diff --git a/src/shared/config/paths.ts b/src/shared/config/paths.ts index 533b9f3..a896020 100644 --- a/src/shared/config/paths.ts +++ b/src/shared/config/paths.ts @@ -11,6 +11,8 @@ export function resolveSessionFilePath(session_id: string) { return join(sessions, `${session_id}.jsonl`); } +export const repos_md = join(home, "REPOS.md"); + export const memory = join(home, "memory"); export const logs = join(memory, "logs"); export function resolveDailyLogFilePath(date: Date) { diff --git a/src/shared/config/predefined-repos.ts b/src/shared/config/predefined-repos.ts new file mode 100644 index 0000000..2af5947 --- /dev/null +++ b/src/shared/config/predefined-repos.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from "node:fs"; + +import { z } from "zod"; + +import * as paths from "./paths"; + +/** + * A predefined repository surfaced by the `/setup` command. The catalog is + * maintained as Markdown in `$AGENTARA_HOME/REPOS.md` rather than YAML so + * the same file can double as free-form context that agents read via + * CLAUDE.md's `@REPOS.md` import — and update in place as they learn more + * about each repo. + * + * `description` is a short one-liner extracted from the first prose line + * in the section; it is what the `/setup` card renders next to the repo + * name. The rest of the section body is consumed by the agent, not by + * the card renderer. + */ +export const PredefinedRepo = z.object({ + name: z.string(), + description: z.string().default(""), + git_url: z.string(), +}); +export interface PredefinedRepo extends z.infer {} + +/** + * Read and parse `$AGENTARA_HOME/REPOS.md`. Returns an empty list when the + * file does not exist or contains no valid repo sections. Called on demand + * by `/setup` (not cached) so operators can edit the file and see the + * change reflected on the next command without restarting the kernel. + */ +export function loadPredefinedRepos(): PredefinedRepo[] { + if (!existsSync(paths.repos_md)) return []; + const raw = readFileSync(paths.repos_md, "utf-8"); + return _parseReposMarkdown(raw); +} + +interface _PartialRepo { + name: string; + git_url?: string; + description?: string; +} + +/** + * Parse a `REPOS.md`-style document. Each `## ` begins a repo; + * the first `- git_url: ` bullet in its body fills `git_url`; the + * first non-bullet, non-heading, non-comment prose line becomes the + * one-line `description`. + */ +function _parseReposMarkdown(markdown: string): PredefinedRepo[] { + const lines = markdown.split(/\r?\n/); + const out: PredefinedRepo[] = []; + let current: _PartialRepo | null = null; + let descriptionCaptured = false; + let inHtmlComment = false; + + const flush = () => { + if (current && current.name && current.git_url) { + const parsed = PredefinedRepo.safeParse({ + name: current.name, + git_url: current.git_url, + description: current.description ?? "", + }); + if (parsed.success) out.push(parsed.data); + } + }; + + for (const line of lines) { + if (inHtmlComment) { + if (line.includes("-->")) inHtmlComment = false; + continue; + } + if (line.trim().startsWith("")) { + inHtmlComment = true; + continue; + } + + const h2 = /^##\s+(.+?)\s*$/.exec(line); + if (h2) { + flush(); + current = { name: h2[1]!.trim() }; + descriptionCaptured = false; + continue; + } + if (!current) continue; + + const gitUrl = /^\s*-\s*git_url\s*:\s*(\S.*?)\s*$/.exec(line); + if (gitUrl) { + current.git_url = gitUrl[1]; + continue; + } + + if (!descriptionCaptured) { + const trimmed = line.trim(); + if ( + trimmed && + !trimmed.startsWith("-") && + !trimmed.startsWith("#") && + !trimmed.startsWith(" `; diff --git a/src/shared/config/predefined-repos.ts b/src/shared/config/predefined-repos.ts index 2af5947..efddfbf 100644 --- a/src/shared/config/predefined-repos.ts +++ b/src/shared/config/predefined-repos.ts @@ -11,10 +11,11 @@ import * as paths from "./paths"; * CLAUDE.md's `@REPOS.md` import — and update in place as they learn more * about each repo. * - * `description` is a short one-liner extracted from the first prose line - * in the section; it is what the `/setup` card renders next to the repo - * name. The rest of the section body is consumed by the agent, not by - * the card renderer. + * `description` is a short one-liner shown on the `/setup` card next to + * the repo name. Prefer an explicit `- description: ` bullet + * in the section so the card copy stays short; if absent we fall back to + * the first prose line for backward compatibility. The rest of the section + * body is free-form agent context, not used by the card renderer. */ export const PredefinedRepo = z.object({ name: z.string(), @@ -43,15 +44,17 @@ interface _PartialRepo { /** * Parse a `REPOS.md`-style document. Each `## ` begins a repo; - * the first `- git_url: ` bullet in its body fills `git_url`; the - * first non-bullet, non-heading, non-comment prose line becomes the - * one-line `description`. + * the first `- git_url: ` bullet fills `git_url`; a + * `- description: ` bullet, if present, fills `description`. + * If that bullet is missing, we fall back to the first non-bullet, + * non-heading, non-comment prose line so older files still render. */ function _parseReposMarkdown(markdown: string): PredefinedRepo[] { const lines = markdown.split(/\r?\n/); const out: PredefinedRepo[] = []; let current: _PartialRepo | null = null; - let descriptionCaptured = false; + let proseFallbackCaptured = false; + let hasExplicitDescription = false; let inHtmlComment = false; const flush = () => { @@ -79,7 +82,8 @@ function _parseReposMarkdown(markdown: string): PredefinedRepo[] { if (h2) { flush(); current = { name: h2[1]!.trim() }; - descriptionCaptured = false; + proseFallbackCaptured = false; + hasExplicitDescription = false; continue; } if (!current) continue; @@ -90,7 +94,14 @@ function _parseReposMarkdown(markdown: string): PredefinedRepo[] { continue; } - if (!descriptionCaptured) { + const desc = /^\s*-\s*description\s*:\s*(\S.*?)\s*$/.exec(line); + if (desc) { + current.description = desc[1]; + hasExplicitDescription = true; + continue; + } + + if (!hasExplicitDescription && !proseFallbackCaptured) { const trimmed = line.trim(); if ( trimmed && @@ -100,7 +111,7 @@ function _parseReposMarkdown(markdown: string): PredefinedRepo[] { !trimmed.startsWith(">") ) { current.description = trimmed; - descriptionCaptured = true; + proseFallbackCaptured = true; } } } From e0abbf1237ab162d5f4b605c956141a5f6a7774a Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 13:20:59 +0800 Subject: [PATCH 12/69] =?UTF-8?q?feat(commands):=20simplify=20/bind=20to?= =?UTF-8?q?=20chat=E2=86=94workspace=20binding=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/bind` previously did double duty — ensure a binding row *and* set the active repo/branch — which overlapped with `/setup`. Narrow it to just the binding side: no args, ensures the group has a workspace (creating it on disk if missing), and leaves active_repo/active_branch alone. Active repo/branch selection now lives entirely in `/setup`'s interactive card, which is a better fit for its multi-repo picker and branch inputs. --- src/kernel/commands/handlers.ts | 35 ++++++++++++--------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 4f5dac1..6450837 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -40,33 +40,24 @@ async function execGit( const bindHandler: CommandHandler = { name: "bind", - description: "/bind <仓库> <分支> — 绑定当前群到指定仓库和分支", + description: "/bind — 绑定当前群到一个 workspace(不存在则创建)", async execute(ctx) { const chatId = requireChatId(ctx); if (!chatId) return "❌ /bind 仅在飞书群内可用。"; - const [repo, branch] = ctx.args; - if (!repo || !branch) { - return "用法:`/bind <仓库目录名> <分支>`\n提示:若还未克隆,先用 `/clone `。"; - } - const resolution = ctx.workspaceStore.resolve(chatId); - const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; - const repoPath = join(workspacePath, repo); - if (!existsSync(repoPath) || !existsSync(join(repoPath, ".git"))) { - return `❌ 在 workspace 中找不到 \`${repo}\`(\`${workspacePath}\`)。请先 \`/clone \`,或用 \`/ls\` 查看已克隆的仓库。`; - } - const checkout = await execGit(["checkout", branch], repoPath); - if (!checkout.ok) { - return `❌ \`git checkout ${branch}\` 失败:\n\`\`\`\n${checkout.stderr || checkout.stdout}\n\`\`\``; + const existing = ctx.workspaceStore.getBinding(chatId); + // Empty patch: just ensure the binding row + workspace dir exist; don't + // touch active_repo/active_branch (those are managed by /setup). + const binding = ctx.workspaceStore.upsertBinding(chatId, {}); + ctx.logger.info({ chat_id: chatId, binding }, "group binding ensured"); + if (existing) { + return [ + `ℹ️ 当前群已绑定 workspace:\`${binding.workspace_path}\``, + "使用 `/setup` 克隆或切换仓库、分支。", + ].join("\n"); } - const binding = ctx.workspaceStore.upsertBinding(chatId, { - active_repo: repo, - active_branch: branch, - }); - ctx.logger.info({ chat_id: chatId, binding }, "group binding updated"); return [ - `✅ 已将当前群绑定到 \`${repo}\` @ \`${branch}\``, - `Workspace:\`${binding.workspace_path}\``, - `后续消息将使用该仓库和分支,直到再次 \`/bind\` 或 \`/unbind\`。`, + `✅ 已为当前群创建 workspace:\`${binding.workspace_path}\``, + "使用 `/setup` 克隆仓库并选择主仓库和分支。", ].join("\n"); }, }; From 148def0922ece18a959bea3e1912a3e37114fe28 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 13:21:15 +0800 Subject: [PATCH 13/69] feat(setup): re-runnable /setup with locked checkers for existing repos `/setup` previously refused to run a second time on an already-bound group, forcing an `/unbind` to add a repo or switch a branch. Make it re-runnable: the card is the single place to configure the workspace. On re-run we probe the workspace for catalog repos that are already cloned and render them with `checked: true` + `disabled: true` so they cannot be dropped by accident, pre-fill the branch input with each repo's current HEAD (editing it = branch switch on submit), and default the primary selector to the current active_repo. Since some Feishu clients don't echo disabled checker state back on submit, the flow tracks locked repos in its pending state and force- includes them when parsing form values. --- .../messaging/types/interactive/elements.ts | 1 + src/kernel/setup/setup-card.ts | 83 ++++++++++++++---- src/kernel/setup/setup-flow.ts | 84 +++++++++++++++---- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/src/community/feishu/messaging/types/interactive/elements.ts b/src/community/feishu/messaging/types/interactive/elements.ts index 90a816f..d694c8c 100644 --- a/src/community/feishu/messaging/types/interactive/elements.ts +++ b/src/community/feishu/messaging/types/interactive/elements.ts @@ -129,6 +129,7 @@ export interface CheckerElement extends BaseElement<"checker"> { name: string; text: PlainTextElement; checked?: boolean; + disabled?: boolean; } export interface SelectOption { diff --git a/src/kernel/setup/setup-card.ts b/src/kernel/setup/setup-card.ts index 6ed57d0..4b0eca1 100644 --- a/src/kernel/setup/setup-card.ts +++ b/src/kernel/setup/setup-card.ts @@ -22,6 +22,26 @@ export const SETUP_FIELD = { primaryRepo: "primary_repo", } as const; +/** + * Per-repo pre-fill state for re-runs of `/setup`. When a repo is already + * cloned in the workspace we surface it on the card as `checked: true` + + * `disabled: true` so the user cannot uncheck it, and pre-fill the branch + * input with the repo's current HEAD so editing = branch switch on submit. + */ +export interface RepoPrefill { + /** Repo is already cloned → checker is forced on and disabled. */ + already_cloned: boolean; + /** Branch to pre-fill in the input (current HEAD for existing repos). */ + current_branch?: string; +} + +export interface SetupCardOptions { + /** Keyed by repo name. Missing entries render as unchecked + editable. */ + prefills?: Record; + /** Primary repo to preselect in the bottom dropdown (usually current active_repo). */ + primary_repo?: string; +} + /** * Build the interactive `/setup` card. * @@ -33,17 +53,26 @@ export const SETUP_FIELD = { * server receives `action.name = "setup_submit"` and `action.form_value` * carries the checker + input + select values. * + * Re-runs pass `options.prefills` so already-cloned repos render as + * locked-on checkers with their current branch pre-filled; new catalog + * entries render as unchecked and can be picked to be added. + * * Card-to-pending correlation happens on the kernel side via `message_id`, * so the card itself carries no setup_id. */ -export function buildSetupCard(catalog: PredefinedRepo[]): Card { +export function buildSetupCard( + catalog: PredefinedRepo[], + options: SetupCardOptions = {}, +): Card { const formElements: Element[] = []; + const prefills = options.prefills ?? {}; + const hasExisting = Object.values(prefills).some((p) => p.already_cloned); for (const repo of catalog) { - formElements.push(_buildRepoRow(repo)); + formElements.push(_buildRepoRow(repo, prefills[repo.name])); } - formElements.push(_buildPrimarySelect(catalog)); + formElements.push(_buildPrimarySelect(catalog, options.primary_repo)); formElements.push(_buildSubmitButton()); const form: FormElement = { @@ -52,14 +81,22 @@ export function buildSetupCard(catalog: PredefinedRepo[]): Card { elements: formElements, }; + const headerLines = hasExisting + ? [ + "**📦 更新当前群的 workspace**", + "", + "已绑定的仓库保持勾选(不可取消)。可以修改其分支,或勾选新仓库加入。", + "底部「主仓库」可切换后续消息默认使用的仓库。", + ] + : [ + "**📦 初始化当前群的 workspace**", + "", + "勾选要克隆的仓库;分支默认 `master`,留空即使用 master。", + "选多个仓库时,请在底部选一个作为「主仓库」(后续消息的默认仓库)。", + ]; const header: MarkdownElement = { tag: "markdown", - content: [ - "**📦 初始化当前群的 workspace**", - "", - "勾选要克隆的仓库;分支默认 `master`,留空即使用 master。", - "选多个仓库时,请在底部选一个作为「主仓库」(后续消息的默认仓库)。", - ].join("\n"), + content: headerLines.join("\n"), }; return { @@ -68,7 +105,9 @@ export function buildSetupCard(catalog: PredefinedRepo[]): Card { streaming_mode: false, update_multi: true, width_mode: "fill", - summary: { content: "📦 初始化 workspace" }, + summary: { + content: hasExisting ? "📦 更新 workspace" : "📦 初始化 workspace", + }, }, body: { elements: [header, form], @@ -76,23 +115,32 @@ export function buildSetupCard(catalog: PredefinedRepo[]): Card { }; } -function _buildRepoRow(repo: PredefinedRepo): ColumnSetElement { +function _buildRepoRow( + repo: PredefinedRepo, + prefill?: RepoPrefill, +): ColumnSetElement { // NOTE: Feishu's checker.text ONLY accepts `plain_text`, not `markdown`. // Attempting markdown yields "type of element is not supported tag: markdown" // (error 200621). We inline the description into the label as a plain string. const label = repo.description ? `${repo.name} — ${repo.description}` : repo.name; + const alreadyCloned = prefill?.already_cloned === true; const checker: CheckerElement = { tag: "checker", name: SETUP_FIELD.repoChecker(repo.name), text: { tag: "plain_text", content: label }, - checked: false, + checked: alreadyCloned, + // Already-cloned repos are locked on so the user can't accidentally drop + // an existing binding. New catalog entries stay fully editable. + disabled: alreadyCloned, }; const branchInput: InputElement = { tag: "input", name: SETUP_FIELD.branchInput(repo.name), placeholder: { tag: "plain_text", content: "master" }, + // Pre-fill with the repo's current branch so editing this field = switch. + default_value: prefill?.current_branch, width: "fill", }; @@ -107,12 +155,19 @@ function _buildRepoRow(repo: PredefinedRepo): ColumnSetElement { }; } -function _buildPrimarySelect(catalog: PredefinedRepo[]): SelectStaticElement { +function _buildPrimarySelect( + catalog: PredefinedRepo[], + preselected?: string, +): SelectStaticElement { + const initial = + preselected && catalog.some((r) => r.name === preselected) + ? preselected + : catalog[0]?.name; return { tag: "select_static", name: SETUP_FIELD.primaryRepo, placeholder: { tag: "plain_text", content: "选择主仓库(默认第一个)" }, - initial_option: catalog[0]?.name, + initial_option: initial, options: catalog.map((r) => ({ text: { tag: "plain_text", content: r.name }, value: r.name, diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index a1532e9..319f795 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -18,6 +18,7 @@ import { buildSetupCard, buildSetupResultCard, SETUP_FIELD, + type RepoPrefill, } from "./setup-card"; /** @@ -29,6 +30,12 @@ interface PendingSetup { chat_id: string; initiator_open_id: string; catalog_snapshot: PredefinedRepo[]; + /** + * Repo names that were already cloned when the card was rendered. + * Their checkers are disabled on the card, so form submissions may not + * echo their checked state back — we force-include them on submit. + */ + locked_repos: Set; created_at: number; } @@ -77,8 +84,10 @@ export class SetupFlow { /** * Entry point invoked from `kernel._handleInboundMessage` when the inbound - * text is `/setup`. Sends back either a plain-text error (no catalog, - * already bound, ...) or the interactive card. + * text is `/setup`. Re-runnable: on a second invocation the card renders + * already-cloned repos as locked checkers with their current branches + * pre-filled, so the user can add new repos or switch branches without + * being able to accidentally drop existing ones. */ async start(message: UserMessage): Promise { const chatId = message.chat_id; @@ -94,23 +103,24 @@ export class SetupFlow { ); return; } - const existing = this._workspaceStore.getBinding(chatId); - if (existing) { - const repo = existing.active_repo ?? "(未设置)"; - const branch = existing.active_branch ?? "(未设置)"; - await this._replyText( - message, - `❌ 当前群已绑定 \`${repo}\` @ \`${branch}\`,请先 \`/unbind\`。`, - ); - return; - } const channel = this._feishuChannels.get(message.channel_id); if (!channel) { await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); return; } - const card = buildSetupCard(catalog); + // Re-runs are allowed. Ensure the binding row + workspace dir exist so + // we can inspect what's already cloned and pre-fill the card accordingly. + const binding = this._workspaceStore.upsertBinding(chatId, {}); + const { prefills, lockedRepos } = this._buildPrefills( + binding.workspace_path, + catalog, + ); + + const card = buildSetupCard(catalog, { + prefills, + primary_repo: binding.active_repo ?? undefined, + }); const cardMessageId = await channel.sendRawCard(chatId, card, { replyTo: message.id, }); @@ -118,6 +128,7 @@ export class SetupFlow { chat_id: chatId, initiator_open_id: message.sender_open_id ?? "", catalog_snapshot: catalog, + locked_repos: lockedRepos, created_at: Date.now(), }); this._logger.info( @@ -172,6 +183,7 @@ export class SetupFlow { const selections = this._parseFormValue( payload.form_value, pending.catalog_snapshot, + pending.locked_repos, ); if (selections.length === 0) { await this._tryUpdateCard( @@ -279,12 +291,17 @@ export class SetupFlow { private _parseFormValue( formValue: Record, catalog: PredefinedRepo[], + lockedRepos: Set, ): Array<{ repo: PredefinedRepo; name: string; branch: string }> { const out: Array<{ repo: PredefinedRepo; name: string; branch: string }> = []; for (const repo of catalog) { const rawChecked = formValue[SETUP_FIELD.repoChecker(repo.name)]; - if (!_isTruthyChecker(rawChecked)) continue; + // Locked repos are rendered with disabled checkers — some Feishu + // clients don't echo disabled values back, so force-include them. + const isSelected = + lockedRepos.has(repo.name) || _isTruthyChecker(rawChecked); + if (!isSelected) continue; const rawBranch = formValue[SETUP_FIELD.branchInput(repo.name)]; const branch = typeof rawBranch === "string" && rawBranch.trim() @@ -295,6 +312,31 @@ export class SetupFlow { return out; } + /** + * Compute per-repo pre-fill state for the card: which catalog repos are + * already cloned in this workspace and what branch each is currently on. + * The set of locked repo names is tracked so the submit handler can + * force-include them even if the card's disabled checker swallows the + * checked state. + */ + private _buildPrefills( + workspacePath: string, + catalog: PredefinedRepo[], + ): { prefills: Record; lockedRepos: Set } { + const prefills: Record = {}; + const lockedRepos = new Set(); + for (const repo of catalog) { + const repoPath = join(workspacePath, repo.name); + if (!existsSync(join(repoPath, ".git"))) continue; + lockedRepos.add(repo.name); + prefills[repo.name] = { + already_cloned: true, + current_branch: _readCurrentBranch(repoPath), + }; + } + return { prefills, lockedRepos }; + } + private async _cloneAndCheckout( workspacePath: string, sel: { repo: PredefinedRepo; name: string; branch: string }, @@ -420,6 +462,20 @@ function _summarizeFeishuError( }; } +function _readCurrentBranch(repoPath: string): string | undefined { + try { + const proc = Bun.spawnSync( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + { cwd: repoPath, stdout: "pipe", stderr: "pipe" }, + ); + if (proc.exitCode !== 0) return undefined; + const out = proc.stdout.toString().trim(); + return out && out !== "HEAD" ? out : undefined; + } catch { + return undefined; + } +} + async function _execGit( args: string[], cwd: string, From a393bfcf23cd03ba66195f0a7d3eb03cbb7d7f09 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 15:14:57 +0800 Subject: [PATCH 14/69] feat(workspace): stable `ws_xxx` ids for multi-group binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the workspace model so multiple Feishu groups can share a single on-disk workspace, with a stable id that survives renames and unbinds. Schema (`drizzle/0011`, `0012`): - Split `group_workspaces` into a binding table + a new `workspaces` registry keyed by `ws_xxx`. Bindings are thin (chat_id -> workspace_id). - Move `active_repo`/`active_branch` up to the workspace — only one `.git/HEAD` exists per clone, so the active state is workspace-scoped, not binding-scoped. Groups sharing a workspace see the same state. - Backfill migrations mint ids for every pre-existing binding, then fold the active state up from the old binding rows. On-disk layout: - Workspace directories are now named after the stable id (`workspaces/ws_xxx/`) so `name` is a pure display label and can be edited freely without moving files. A boot-time normalizer renames legacy chat-id-keyed or slug-keyed dirs into place. - Each workspace root carries an `AGENTARA.md` meta file with id, name, bound chats, and active repo/branch — auto-refreshed on every bind, unbind, rename, or active state change. Commands: - `/bind` stays as "no-args → ensure a workspace exists"; after unbind it now mints a fresh workspace instead of silently re-adopting the orphan by path. - `/bind ` is new: attach the current chat to an existing workspace (errors if the id doesn't exist). Inherits active repo/ branch from the workspace — no longer resets to null. - `/status` surfaces workspace id and name so operators can share the id across groups. Setup card: - Adds a workspace-name input that defaults to `-workspace` (fetched via `im.chat.get`; falls back to a chat-id slug). The field stays editable on re-runs — renaming just updates `workspaces.name`, never touches the directory. - Stacks label/input/id-hint vertically so narrow cards don't squeeze the layout; submit button renamed from "初始化" to "提交" to match the re-runnable semantics. --- drizzle/0011_workspace_ids.sql | 65 ++++ drizzle/0012_workspace_active_state.sql | 33 ++ drizzle/meta/0011_snapshot.json | 362 ++++++++++++++++++ drizzle/meta/0012_snapshot.json | 362 ++++++++++++++++++ drizzle/meta/_journal.json | 14 + .../feishu/messaging/message-channel.ts | 20 + src/kernel/commands/handlers.ts | 50 ++- src/kernel/sessioning/data/schema.ts | 46 ++- src/kernel/setup/setup-card.ts | 41 +- src/kernel/setup/setup-flow.ts | 116 +++++- src/kernel/workspaces/store.ts | 344 +++++++++++++++-- src/shared/config/paths.ts | 15 +- src/shared/utils/index.ts | 1 + src/shared/utils/slugify-workspace-name.ts | 31 ++ .../workspaces/types/group-workspace.ts | 33 +- src/shared/workspaces/types/index.ts | 1 + src/shared/workspaces/types/workspace.ts | 30 ++ 17 files changed, 1482 insertions(+), 82 deletions(-) create mode 100644 drizzle/0011_workspace_ids.sql create mode 100644 drizzle/0012_workspace_active_state.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 drizzle/meta/0012_snapshot.json create mode 100644 src/shared/utils/slugify-workspace-name.ts create mode 100644 src/shared/workspaces/types/workspace.ts diff --git a/drizzle/0011_workspace_ids.sql b/drizzle/0011_workspace_ids.sql new file mode 100644 index 0000000..2272e93 --- /dev/null +++ b/drizzle/0011_workspace_ids.sql @@ -0,0 +1,65 @@ +CREATE TABLE `workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `workspaces_path_unique` ON `workspaces` (`path`); +--> statement-breakpoint +WITH RECURSIVE `workspace_name_parts`(`chat_id`, `path`, `tail`) AS ( + SELECT + `chat_id`, + rtrim(`workspace_path`, '/'), + rtrim(`workspace_path`, '/') + FROM `group_workspaces` + UNION ALL + SELECT + `chat_id`, + `path`, + substr(`tail`, instr(`tail`, '/') + 1) + FROM `workspace_name_parts` + WHERE instr(`tail`, '/') > 0 +) +INSERT OR IGNORE INTO `workspaces` (`id`, `name`, `path`, `created_at`, `updated_at`) +SELECT + 'ws_' || lower(hex(randomblob(6))), + COALESCE(NULLIF(`parts`.`tail`, ''), '_workspace'), + `parts`.`path`, + `gw`.`created_at`, + `gw`.`updated_at` +FROM `workspace_name_parts` AS `parts` +JOIN `group_workspaces` AS `gw` ON `gw`.`chat_id` = `parts`.`chat_id` +WHERE instr(`parts`.`tail`, '/') = 0; +--> statement-breakpoint +ALTER TABLE `group_workspaces` RENAME TO `group_workspaces__old`; +--> statement-breakpoint +CREATE TABLE `group_workspaces` ( + `chat_id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `active_repo` text, + `active_branch` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +INSERT INTO `group_workspaces` ( + `chat_id`, + `workspace_id`, + `active_repo`, + `active_branch`, + `created_at`, + `updated_at` +) +SELECT + `old`.`chat_id`, + `ws`.`id`, + `old`.`active_repo`, + `old`.`active_branch`, + `old`.`created_at`, + `old`.`updated_at` +FROM `group_workspaces__old` AS `old` +JOIN `workspaces` AS `ws` ON `ws`.`path` = `old`.`workspace_path`; +--> statement-breakpoint +DROP TABLE `group_workspaces__old`; diff --git a/drizzle/0012_workspace_active_state.sql b/drizzle/0012_workspace_active_state.sql new file mode 100644 index 0000000..e61ad0f --- /dev/null +++ b/drizzle/0012_workspace_active_state.sql @@ -0,0 +1,33 @@ +-- Move active_repo/active_branch from `group_workspaces` up to `workspaces`. +-- Only one `.git/HEAD` exists per cloned repo, so the "active" state is a +-- workspace-on-disk property, not a per-binding property. Multiple groups +-- sharing a workspace should see the same active state. +ALTER TABLE `workspaces` ADD `active_repo` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `active_branch` text;--> statement-breakpoint +-- Backfill: for each workspace, copy the active state from any existing +-- binding. Pre-migration each workspace maps 1:1 to a chat, so any binding +-- row is authoritative; if a shared workspace somehow exists, prefer the +-- most recently updated binding. +UPDATE `workspaces` +SET + `active_repo` = ( + SELECT `gw`.`active_repo` + FROM `group_workspaces` AS `gw` + WHERE `gw`.`workspace_id` = `workspaces`.`id` + ORDER BY `gw`.`updated_at` DESC + LIMIT 1 + ), + `active_branch` = ( + SELECT `gw`.`active_branch` + FROM `group_workspaces` AS `gw` + WHERE `gw`.`workspace_id` = `workspaces`.`id` + ORDER BY `gw`.`updated_at` DESC + LIMIT 1 + ) +WHERE EXISTS ( + SELECT 1 + FROM `group_workspaces` AS `gw` + WHERE `gw`.`workspace_id` = `workspaces`.`id` +);--> statement-breakpoint +ALTER TABLE `group_workspaces` DROP COLUMN `active_repo`;--> statement-breakpoint +ALTER TABLE `group_workspaces` DROP COLUMN `active_branch`; diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..70fa243 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,362 @@ +{ + "id": "224de30f-7d66-48ff-8940-1737e3db056e", + "prevId": "9c5034a3-52eb-43ee-a6c8-f2d11a99d8b6", + "version": "6", + "dialect": "sqlite", + "tables": { + "group_workspaces": { + "name": "group_workspaces", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_repo": { + "name": "active_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_branch": { + "name": "active_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_path_unique": { + "name": "workspaces_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..27a4e20 --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,362 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8d12163c-11da-4e53-b925-55107b8e5832", + "prevId": "224de30f-7d66-48ff-8940-1737e3db056e", + "tables": { + "group_workspaces": { + "name": "group_workspaces", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_repo": { + "name": "active_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_branch": { + "name": "active_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_path_unique": { + "name": "workspaces_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2a72ca4..40409cb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,20 @@ "when": 1776319736541, "tag": "0010_lean_enchantress", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1776407449569, + "tag": "0011_workspace_ids", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1776408029760, + "tag": "0012_workspace_active_state", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 29be917..a11a9ff 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -213,6 +213,26 @@ export class FeishuMessageChannel return data.message_id; } + /** + * Best-effort fetch of a chat's display name. The bot must be a member of + * the chat, with `im:chat` or `im:chat:readonly` scope. Returns undefined + * on any failure (permission denied, chat not found, network error) so + * callers can fall back to a deterministic default. + */ + async getChatName(chatId: string): Promise { + try { + const { data } = await this._client.im.chat.get({ + path: { chat_id: chatId }, + }); + const name = + data?.i18n_names?.zh_cn ?? data?.name ?? data?.i18n_names?.en_us; + return typeof name === "string" && name.trim() ? name.trim() : undefined; + } catch (err) { + this._logger.warn({ err, chat_id: chatId }, "getChatName failed"); + return undefined; + } + } + /** * Replace the content of an existing interactive card message. Used by * card-driven flows to transition the same message from "pending" to diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 6450837..9821345 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -40,10 +40,38 @@ async function execGit( const bindHandler: CommandHandler = { name: "bind", - description: "/bind — 绑定当前群到一个 workspace(不存在则创建)", + description: "/bind [workspace-id] — 绑定当前群到一个 workspace;传 id 时复用已有空间", async execute(ctx) { const chatId = requireChatId(ctx); if (!chatId) return "❌ /bind 仅在飞书群内可用。"; + const [workspaceId] = ctx.args; + if (workspaceId) { + const workspace = ctx.workspaceStore.getWorkspace(workspaceId); + if (!workspace) { + return `❌ workspace id \`${workspaceId}\` 不存在。先在已有群里执行 \`/status\` 或 \`/setup\` 获取正确的 id。`; + } + // Don't reset active_repo/active_branch here — they live on the + // workspace and are shared by every bound group. Inherit whatever the + // workspace already has. + const binding = ctx.workspaceStore.upsertBinding(chatId, { + workspace_id: workspaceId, + }); + ctx.logger.info( + { chat_id: chatId, workspace_id: workspaceId, binding }, + "group rebound to existing workspace", + ); + const activeLine = binding.active_repo + ? `- 活跃仓库:\`${binding.active_repo}\`${ + binding.active_branch ? ` @ \`${binding.active_branch}\`` : "" + }` + : "- 活跃仓库:(未设置,可用 `/setup` 配置)"; + return [ + `✅ 当前群已绑定到已有 workspace:\`${binding.workspace_name}\``, + `- Workspace ID:\`${binding.workspace_id}\``, + `- 路径:\`${binding.workspace_path}\``, + activeLine, + ].join("\n"); + } const existing = ctx.workspaceStore.getBinding(chatId); // Empty patch: just ensure the binding row + workspace dir exist; don't // touch active_repo/active_branch (those are managed by /setup). @@ -51,12 +79,17 @@ const bindHandler: CommandHandler = { ctx.logger.info({ chat_id: chatId, binding }, "group binding ensured"); if (existing) { return [ - `ℹ️ 当前群已绑定 workspace:\`${binding.workspace_path}\``, + `ℹ️ 当前群已绑定 workspace:\`${binding.workspace_name}\``, + `- Workspace ID:\`${binding.workspace_id}\``, + `- 路径:\`${binding.workspace_path}\``, "使用 `/setup` 克隆或切换仓库、分支。", ].join("\n"); } return [ - `✅ 已为当前群创建 workspace:\`${binding.workspace_path}\``, + `✅ 已为当前群创建 workspace:\`${binding.workspace_name}\``, + `- Workspace ID:\`${binding.workspace_id}\``, + `- 路径:\`${binding.workspace_path}\``, + "其他群可通过 `/bind ` 直接复用这个空间。", "使用 `/setup` 克隆仓库并选择主仓库和分支。", ].join("\n"); }, @@ -90,13 +123,16 @@ const statusHandler: CommandHandler = { if (!resolution.binding) { lines.push("ℹ️ 当前群 **未绑定**。"); lines.push(`默认 workspace:\`${resolution.cwd}\``); - lines.push("使用 `/bind <仓库> <分支>` 或 `/clone ` 来初始化。"); + lines.push("使用 `/bind` 创建一个新 workspace,或 `/bind ` 复用已有空间。"); return lines.join("\n"); } lines.push("**当前群绑定:**"); - lines.push(`- Workspace:\`${resolution.binding.workspace_path}\``); + lines.push(`- Workspace ID:\`${resolution.binding.workspace_id}\``); + lines.push(`- Workspace 名称:\`${resolution.binding.workspace_name}\``); + lines.push(`- Workspace 路径:\`${resolution.binding.workspace_path}\``); lines.push(`- 活跃仓库:\`${resolution.binding.active_repo ?? "(未设置)"}\``); lines.push(`- 活跃分支:\`${resolution.binding.active_branch ?? "(未设置)"}\``); + lines.push("- 复用到其他群:`/bind `"); const repos = listRepoBasenames(resolution.binding.workspace_path); if (repos.length > 0) { lines.push("", "**Workspace 中已克隆的仓库:**"); @@ -158,7 +194,7 @@ const cloneHandler: CommandHandler = { } return [ `✅ 已克隆 \`${name}\` 到 workspace。`, - `使用 \`/bind ${name} <分支>\` 将其设为当前群的活跃仓库。`, + "继续执行 `/setup` 选择主仓库和分支。", ].join("\n"); }, }; @@ -202,7 +238,7 @@ export const helpHandler: CommandHandler = { ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), "- /help — 显示本消息", "- /stop — 取消当前 session 正在执行的任务", - "- /setup — 打开交互卡片,从 REPOS.md 里的仓库目录中批量克隆并绑定", + "- /setup — 打开交互卡片,创建或更新 workspace,并设置主仓库/分支", ].join("\n"); }, }; diff --git a/src/kernel/sessioning/data/schema.ts b/src/kernel/sessioning/data/schema.ts index 0c55704..be1555c 100644 --- a/src/kernel/sessioning/data/schema.ts +++ b/src/kernel/sessioning/data/schema.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; /** * Persisted session records that track session metadata across restarts. @@ -31,23 +31,51 @@ export const sessions = sqliteTable("sessions", { updated_at: integer("updated_at").notNull(), }); +/** + * Persisted workspace registry. A workspace has its own stable id so multiple + * Feishu groups can bind to the same underlying directory while preserving a + * human-readable name/path for display. + * + * `active_repo` and `active_branch` live here (not on the binding) because + * only one `.git/HEAD` exists per cloned repo — the "active" state is a + * property of the workspace on disk, shared by any group bound to it. + */ +export const workspaces = sqliteTable( + "workspaces", + { + /** Stable workspace id used by `/bind `. */ + id: text("id").primaryKey(), + /** Human-readable workspace name (also the directory basename today). */ + name: text("name").notNull(), + /** Absolute path of the workspace root on disk. */ + path: text("path").notNull(), + /** Basename of the currently focused repo under `path`, or null. */ + active_repo: text("active_repo"), + /** Git branch to check out in the active repo on dispatch, or null. */ + active_branch: text("active_branch"), + /** Epoch milliseconds when the workspace was created. */ + created_at: integer("created_at").notNull(), + /** Epoch milliseconds when the workspace was last updated. */ + updated_at: integer("updated_at").notNull(), + }, + (table) => ({ + path_unique: uniqueIndex("workspaces_path_unique").on(table.path), + }), +); + /** * Persisted group↔workspace bindings. One row per Feishu group that has been * bound via `/bind` or `/setup`. Absence of a row means the group is unbound * and falls back to the default workspace. * - * workspace_path is always `$AGENTARA_HOME/workspaces//`; stored to - * make the value explicit and survive config path changes. + * The binding is a thin many-to-one pointer — active repo/branch belong to + * the workspace itself, so groups bound to the same workspace share them. */ export const groupWorkspaces = sqliteTable("group_workspaces", { /** Feishu chat id. */ chat_id: text("chat_id").primaryKey(), - /** Absolute path of the group's workspace directory. */ - workspace_path: text("workspace_path").notNull(), - /** Basename of the currently focused repo under workspace_path, or null. */ - active_repo: text("active_repo"), - /** Git branch to check out in the active repo on dispatch, or null. */ - active_branch: text("active_branch"), + /** Stable workspace id. */ + workspace_id: text("workspace_id").notNull(), /** Epoch milliseconds when the binding was created. */ created_at: integer("created_at").notNull(), /** Epoch milliseconds when the binding was last updated. */ diff --git a/src/kernel/setup/setup-card.ts b/src/kernel/setup/setup-card.ts index 4b0eca1..4f1e685 100644 --- a/src/kernel/setup/setup-card.ts +++ b/src/kernel/setup/setup-card.ts @@ -17,6 +17,7 @@ import type { * handler. Keep them in one place so the two sides cannot drift apart. */ export const SETUP_FIELD = { + workspaceName: "workspace_name", repoChecker: (name: string) => `repo_${name}`, branchInput: (name: string) => `branch_${name}`, primaryRepo: "primary_repo", @@ -40,6 +41,14 @@ export interface SetupCardOptions { prefills?: Record; /** Primary repo to preselect in the bottom dropdown (usually current active_repo). */ primary_repo?: string; + /** + * Pre-fill + lock state for the workspace-name input. + * - First run: `value` is the suggested default (editable); no `id` yet. + * - Re-run: `value` is the current workspace directory name, `locked: true`, + * `id` is the stable workspace id shown alongside so the user can copy + * it out and `/bind ` from another group. + */ + workspace_name?: { value: string; locked: boolean; id?: string }; } /** @@ -68,6 +77,9 @@ export function buildSetupCard( const prefills = options.prefills ?? {}; const hasExisting = Object.values(prefills).some((p) => p.already_cloned); + if (options.workspace_name) { + formElements.push(..._buildWorkspaceNameInput(options.workspace_name)); + } for (const repo of catalog) { formElements.push(_buildRepoRow(repo, prefills[repo.name])); } @@ -115,6 +127,33 @@ export function buildSetupCard( }; } +function _buildWorkspaceNameInput( + state: NonNullable, +): Element[] { + // Stack label + input vertically so narrow cards (mobile) don't squeeze + // the label into a sliver. The stable id sits on its own line as a small + // annotation below the input; on first run it's omitted entirely. + const label: MarkdownElement = { + tag: "markdown", + content: "**Workspace 名称**", + }; + const input: InputElement = { + tag: "input", + name: SETUP_FIELD.workspaceName, + placeholder: { tag: "plain_text", content: "workspace 名称" }, + default_value: state.value, + width: "fill", + }; + const elements: Element[] = [label, input]; + if (state.id) { + elements.push({ + tag: "markdown", + content: `当前 ID \`${state.id}\``, + }); + } + return elements; +} + function _buildRepoRow( repo: PredefinedRepo, prefill?: RepoPrefill, @@ -189,7 +228,7 @@ function _buildSubmitButton(): ButtonElement { return { tag: "button", name: "setup_submit", - text: { tag: "plain_text", content: "✅ 初始化" }, + text: { tag: "plain_text", content: "提交" }, type: "primary", action_type: "form_submit", }; diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index 319f795..c981086 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -3,10 +3,11 @@ import { join } from "node:path"; import type { Logger } from "@/shared"; import { - config, createLogger, loadPredefinedRepos, + slugifyWorkspaceName, type CardActionPayload, + type GroupWorkspace, type PredefinedRepo, type UserMessage, } from "@/shared"; @@ -36,6 +37,8 @@ interface PendingSetup { * echo their checked state back — we force-include them on submit. */ locked_repos: Set; + /** Existing workspace id, when editing an already-bound workspace. */ + locked_workspace_id?: string; created_at: number; } @@ -109,17 +112,23 @@ export class SetupFlow { return; } - // Re-runs are allowed. Ensure the binding row + workspace dir exist so - // we can inspect what's already cloned and pre-fill the card accordingly. - const binding = this._workspaceStore.upsertBinding(chatId, {}); - const { prefills, lockedRepos } = this._buildPrefills( - binding.workspace_path, - catalog, + // Re-runs reuse the existing workspace path (locked on the card); + // first runs propose a slug derived from the group name and let the + // user customize it. We defer creating the binding/dir until submit. + const binding = this._workspaceStore.getBinding(chatId); + const workspaceNameState = await this._resolveWorkspaceNameState( + chatId, + binding, + channel, ); + const { prefills, lockedRepos } = binding + ? this._buildPrefills(binding.workspace_path, catalog) + : { prefills: {}, lockedRepos: new Set() }; const card = buildSetupCard(catalog, { prefills, - primary_repo: binding.active_repo ?? undefined, + primary_repo: binding?.active_repo ?? undefined, + workspace_name: workspaceNameState, }); const cardMessageId = await channel.sendRawCard(chatId, card, { replyTo: message.id, @@ -129,6 +138,7 @@ export class SetupFlow { initiator_open_id: message.sender_open_id ?? "", catalog_snapshot: catalog, locked_repos: lockedRepos, + locked_workspace_id: binding?.workspace_id, created_at: Date.now(), }); this._logger.info( @@ -220,15 +230,26 @@ export class SetupFlow { "pending-state", ); - const workspacePath = config.paths.resolveGroupWorkspacePath( + // Workspace name is a pure display label — freely editable on every + // run. The directory path is derived from the stable workspace id by + // the store, so changing the name never moves files on disk. We resolve + // the requested name here and pass it through; the store's mutation + // path decides whether to create a new workspace or rename an existing + // one. The old `workspace_path` hint is no longer useful. + const workspaceName = this._resolveWorkspaceNameFromForm( + payload.form_value[SETUP_FIELD.workspaceName], pending.chat_id, ); - if (!existsSync(workspacePath)) { - const { mkdirSync } = await import("node:fs"); - mkdirSync(workspacePath, { recursive: true }); - } const results: RepoResult[] = []; + const provisionalBinding = this._workspaceStore.upsertBinding( + pending.chat_id, + { + workspace_id: pending.locked_workspace_id, + workspace_name: workspaceName, + }, + ); + const workspacePath = provisionalBinding.workspace_path; for (const sel of selections) { results.push(await this._cloneAndCheckout(workspacePath, sel)); } @@ -241,15 +262,21 @@ export class SetupFlow { primaryResult && primaryResult.status !== "clone_failed" ? primary : null; const activeBranch = primaryResult?.actual_branch ?? null; - this._workspaceStore.upsertBinding(pending.chat_id, { + const binding = this._workspaceStore.upsertBinding(pending.chat_id, { active_repo: activeRepo, active_branch: activeBranch, }); - const lines = results.map(_formatResultLine); + const lines = [ + `- Workspace ID: \`${binding.workspace_id}\``, + `- Workspace 名称: \`${binding.workspace_name}\``, + `- Workspace 路径: \`${binding.workspace_path}\``, + "- 其他群可用 `/bind ` 复用这个空间。", + ...results.map(_formatResultLine), + ]; const summary = activeRepo && activeBranch ? `✅ 初始化完成,主仓库 \`${activeRepo}\` @ \`${activeBranch}\`。` - : "❌ 所有仓库克隆失败,未建立绑定。"; + : "⚠️ workspace 已创建,但这次没有成功设置主仓库。"; await this._tryUpdateCard( channel, payload.message_id, @@ -312,6 +339,52 @@ export class SetupFlow { return out; } + /** + * Build the workspace-name input's pre-fill + lock state. + * + * - Re-run (binding exists): the current directory basename is locked in + * and the stable workspace id is surfaced too, so the user can copy it + * out and run `/bind ` from another group. + * - First run: try to fetch the Feishu group name; slugify it and append + * `-workspace` to produce a human-readable default. If the group name + * is unavailable or sluggifies to nothing, fall back to a chat-id + * prefix so the input never starts empty. + */ + private async _resolveWorkspaceNameState( + chatId: string, + binding: GroupWorkspace | null, + channel: FeishuMessageChannel, + ): Promise<{ value: string; locked: boolean; id?: string }> { + if (binding) { + return { + value: binding.workspace_name, + locked: true, + id: binding.workspace_id, + }; + } + const groupName = await channel.getChatName(chatId); + const slug = groupName ? slugifyWorkspaceName(groupName) : ""; + const fallback = _chatIdFallbackSlug(chatId); + const value = slug ? `${slug}-workspace` : `${fallback}-workspace`; + return { value, locked: false }; + } + + /** + * Resolve the workspace path from the form's `workspace_name` field on + * first-run submission. Sluggifies whatever the user typed; if nothing + * usable survives (empty/whitespace-only input), falls back to the + * chat-id-based path so we never write a binding with an empty name. + */ + private _resolveWorkspaceNameFromForm( + rawValue: unknown, + chatId: string, + ): string { + const slug = + typeof rawValue === "string" ? slugifyWorkspaceName(rawValue) : ""; + if (slug) return slug; + return _chatIdFallbackSlug(chatId); + } + /** * Compute per-repo pre-fill state for the card: which catalog repos are * already cloned in this workspace and what branch each is currently on. @@ -462,6 +535,17 @@ function _summarizeFeishuError( }; } +/** + * Readable-ish short form of a Feishu chat id — used when the group name + * is unavailable so the default workspace name is still unique + stable. + * Strips the `oc_` prefix (always present on chat ids) and keeps the first + * 8 chars of the opaque suffix. + */ +function _chatIdFallbackSlug(chatId: string): string { + const stripped = chatId.replace(/^oc_/, ""); + return stripped.slice(0, 8) || "group"; +} + function _readCurrentBranch(repoPath: string): string | undefined { try { const proc = Bun.spawnSync( diff --git a/src/kernel/workspaces/store.ts b/src/kernel/workspaces/store.ts index e5fc456..7603a2c 100644 --- a/src/kernel/workspaces/store.ts +++ b/src/kernel/workspaces/store.ts @@ -1,10 +1,14 @@ -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import dayjs from "dayjs"; import { eq } from "drizzle-orm"; import type { DrizzleDB } from "@/data"; -import { groupWorkspaces } from "@/kernel/sessioning/data"; -import { config, createLogger, type GroupWorkspace } from "@/shared"; +import { groupWorkspaces, workspaces } from "@/kernel/sessioning/data"; +import { config, createLogger, uuid, type GroupWorkspace, type Workspace } from "@/shared"; + +const META_FILE_NAME = "AGENTARA.md"; /** * Resolution result for a group's dispatch context: the cwd to spawn the @@ -31,8 +35,9 @@ export class GroupWorkspaceStore { } /** - * Ensure the default workspace root + `_default` fallback both exist. - * Idempotent; safe to call on every boot. + * Ensure the default workspace root + `_default` fallback both exist, and + * migrate any legacy workspace rows whose on-disk path is not yet keyed by + * their stable id. Idempotent; safe to call on every boot. */ ensureBaseDirs(): void { for (const dir of [config.paths.workspaces, config.paths.default_workspace]) { @@ -41,6 +46,7 @@ export class GroupWorkspaceStore { this._logger.info(`Created workspace dir: ${dir}`); } } + this._normalizeWorkspacePaths(); } /** Fetch the binding for a Feishu chat, or null when none. */ @@ -48,71 +54,143 @@ export class GroupWorkspaceStore { const row = this._db .select() .from(groupWorkspaces) + .innerJoin(workspaces, eq(groupWorkspaces.workspace_id, workspaces.id)) .where(eq(groupWorkspaces.chat_id, chatId)) .get(); - return row ?? null; + return row ? this._mapJoinedBinding(row) : null; } /** List all bindings, most recently updated first. */ listBindings(): GroupWorkspace[] { - return this._db.select().from(groupWorkspaces).all(); + return this._db + .select() + .from(groupWorkspaces) + .innerJoin(workspaces, eq(groupWorkspaces.workspace_id, workspaces.id)) + .all() + .map((row) => this._mapJoinedBinding(row)); + } + + /** Fetch a workspace by stable id. */ + getWorkspace(workspaceId: string): Workspace | null { + const row = this._db + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + return row ?? null; + } + + /** List all known workspaces. */ + listWorkspaces(): Workspace[] { + return this._db.select().from(workspaces).all(); } /** * Upsert the binding for a chat. Creates the row if absent; otherwise - * merges non-undefined fields over the existing row. Ensures the - * workspace directory exists on disk. + * merges non-undefined fields over the existing row. + * + * `workspace_path`/`workspace_name` are only honored when the workspace is + * being created (no `workspace_id` supplied and no existing binding that + * points at one); once a workspace exists, its identity is owned by the + * workspace record. `active_repo`/`active_branch` always go to the + * workspace, so groups sharing it see the same active state. */ upsertBinding( chatId: string, - patch: { active_repo?: string | null; active_branch?: string | null }, + patch: { + active_repo?: string | null; + active_branch?: string | null; + workspace_id?: string; + workspace_name?: string; + workspace_path?: string; + }, ): GroupWorkspace { - const workspacePath = config.paths.resolveGroupWorkspacePath(chatId); - if (!existsSync(workspacePath)) { - mkdirSync(workspacePath, { recursive: true }); - this._logger.info(`Created group workspace: ${workspacePath}`); - } - const now = Date.now(); const existing = this.getBinding(chatId); + const workspace = this._resolveWorkspace(chatId, existing, patch, now); + + // Active repo/branch live on the workspace — apply any non-undefined + // patch values there. If the workspace was just created, it carries the + // caller's active_repo/active_branch directly; for existing workspaces + // we write through so shared bindings all see the update. + const wsActiveRepo = + patch.active_repo === undefined ? workspace.active_repo : patch.active_repo; + const wsActiveBranch = + patch.active_branch === undefined + ? workspace.active_branch + : patch.active_branch; + const wsNameChanged = + patch.workspace_name !== undefined && + patch.workspace_name !== workspace.name; + const wsActiveChanged = + wsActiveRepo !== workspace.active_repo || + wsActiveBranch !== workspace.active_branch; + if (wsActiveChanged || wsNameChanged) { + this._db + .update(workspaces) + .set({ + name: wsNameChanged ? patch.workspace_name! : workspace.name, + active_repo: wsActiveRepo, + active_branch: wsActiveBranch, + updated_at: now, + }) + .where(eq(workspaces.id, workspace.id)) + .run(); + if (wsNameChanged) workspace.name = patch.workspace_name!; + workspace.active_repo = wsActiveRepo; + workspace.active_branch = wsActiveBranch; + workspace.updated_at = now; + } + if (!existing) { - const row: GroupWorkspace = { + this._db + .insert(groupWorkspaces) + .values({ + chat_id: chatId, + workspace_id: workspace.id, + created_at: now, + updated_at: now, + }) + .run(); + this._writeMetaFile(workspace); + return { chat_id: chatId, - workspace_path: workspacePath, - active_repo: patch.active_repo ?? null, - active_branch: patch.active_branch ?? null, + workspace_id: workspace.id, + workspace_name: workspace.name, + workspace_path: workspace.path, + active_repo: wsActiveRepo, + active_branch: wsActiveBranch, created_at: now, updated_at: now, }; - this._db.insert(groupWorkspaces).values(row).run(); - return row; } - const merged: GroupWorkspace = { - ...existing, - workspace_path: workspacePath, - active_repo: - patch.active_repo === undefined ? existing.active_repo : patch.active_repo, - active_branch: - patch.active_branch === undefined - ? existing.active_branch - : patch.active_branch, - updated_at: now, - }; this._db .update(groupWorkspaces) .set({ - workspace_path: merged.workspace_path, - active_repo: merged.active_repo, - active_branch: merged.active_branch, - updated_at: merged.updated_at, + workspace_id: workspace.id, + updated_at: now, }) .where(eq(groupWorkspaces.chat_id, chatId)) .run(); - return merged; + this._writeMetaFile(workspace); + return { + ...existing, + workspace_id: workspace.id, + workspace_name: workspace.name, + workspace_path: workspace.path, + active_repo: wsActiveRepo, + active_branch: wsActiveBranch, + updated_at: now, + }; } - /** Remove the binding for a chat. No-op when absent. */ + /** + * Remove the binding for a chat. No-op when absent. The workspace itself + * is kept — other groups may still be bound to it, and even when none are, + * we hold onto it so files in the directory aren't forgotten. Re-binding + * via `/bind ` remains possible after an unbind. + */ deleteBinding(chatId: string): boolean { const existing = this.getBinding(chatId); if (!existing) return false; @@ -120,6 +198,8 @@ export class GroupWorkspaceStore { .delete(groupWorkspaces) .where(eq(groupWorkspaces.chat_id, chatId)) .run(); + const workspace = this.getWorkspace(existing.workspace_id); + if (workspace) this._writeMetaFile(workspace); return true; } @@ -170,4 +250,190 @@ export class GroupWorkspaceStore { binding, }; } + + private _resolveWorkspace( + chatId: string, + existing: GroupWorkspace | null, + patch: { + workspace_id?: string; + workspace_name?: string; + workspace_path?: string; + }, + now: number, + ): Workspace { + if (patch.workspace_id) { + const workspace = this.getWorkspace(patch.workspace_id); + if (!workspace) { + throw new Error(`workspace id "${patch.workspace_id}" does not exist`); + } + this._ensureWorkspaceDir(workspace.path); + return workspace; + } + + if (existing?.workspace_id) { + const workspace = this.getWorkspace(existing.workspace_id); + if (!workspace) { + throw new Error( + `workspace id "${existing.workspace_id}" referenced by chat "${chatId}" does not exist`, + ); + } + this._ensureWorkspaceDir(workspace.path); + return workspace; + } + + // Brand-new workspace: directory path is always derived from the stable + // id so `name` stays a pure display label. `patch.workspace_name` (or + // `workspace_path`'s basename as a last resort) only seeds the display + // name; it does not affect the on-disk directory. + const id = this._newWorkspaceId(); + const workspacePath = config.paths.resolveWorkspacePathById(id); + const workspaceName = + patch.workspace_name ?? + (patch.workspace_path ? _safeBasename(patch.workspace_path) : id); + this._ensureWorkspaceDir(workspacePath); + const workspace: Workspace = { + id, + name: workspaceName, + path: workspacePath, + active_repo: null, + active_branch: null, + created_at: now, + updated_at: now, + }; + this._db.insert(workspaces).values(workspace).run(); + this._writeMetaFile(workspace); + return workspace; + } + + private _ensureWorkspaceDir(workspacePath: string): void { + if (!existsSync(workspacePath)) { + mkdirSync(workspacePath, { recursive: true }); + this._logger.info(`Created workspace: ${workspacePath}`); + } + } + + private _newWorkspaceId(): string { + return `ws_${uuid().replace(/-/g, "").slice(0, 12)}`; + } + + private _mapJoinedBinding(row: { + group_workspaces: typeof groupWorkspaces.$inferSelect; + workspaces: typeof workspaces.$inferSelect; + }): GroupWorkspace { + return { + chat_id: row.group_workspaces.chat_id, + workspace_id: row.workspaces.id, + workspace_name: row.workspaces.name, + workspace_path: row.workspaces.path, + active_repo: row.workspaces.active_repo, + active_branch: row.workspaces.active_branch, + created_at: row.group_workspaces.created_at, + updated_at: row.group_workspaces.updated_at, + }; + } + + /** + * Boot-time fixup: move legacy workspace directories (named after chat_ids + * or user-picked slugs) to id-keyed paths. Idempotent; any row whose + * on-disk path already matches `workspaces//` is skipped. If a legacy + * directory exists on disk we `rename` it in place; if it's already gone + * we just create a fresh empty directory at the new path. + */ + private _normalizeWorkspacePaths(): void { + const rows = this._db.select().from(workspaces).all(); + const now = Date.now(); + for (const row of rows) { + const desired = config.paths.resolveWorkspacePathById(row.id); + if (row.path === desired && existsSync(desired)) { + this._writeMetaFile(row); + continue; + } + if (row.path !== desired && existsSync(row.path) && !existsSync(desired)) { + try { + renameSync(row.path, desired); + this._logger.info( + { from: row.path, to: desired, workspace_id: row.id }, + "normalized workspace path", + ); + } catch (err) { + this._logger.error( + { err, from: row.path, to: desired, workspace_id: row.id }, + "failed to normalize workspace path; leaving as-is", + ); + continue; + } + } else if (!existsSync(desired)) { + this._ensureWorkspaceDir(desired); + } + this._db + .update(workspaces) + .set({ path: desired, updated_at: now }) + .where(eq(workspaces.id, row.id)) + .run(); + this._writeMetaFile({ ...row, path: desired, updated_at: now }); + } + } + + /** + * Write / overwrite the `AGENTARA.md` info file at the workspace root. + * This is a human-readable summary of the workspace: stable id, display + * name, on-disk path, bound chats, and active repo/branch. Safe to + * regenerate on every mutation; the file is marked as auto-generated. + */ + private _writeMetaFile(workspace: Workspace): void { + try { + const bindings = this._db + .select({ chat_id: groupWorkspaces.chat_id }) + .from(groupWorkspaces) + .where(eq(groupWorkspaces.workspace_id, workspace.id)) + .all(); + const chatLines = + bindings.length === 0 + ? "- _(none — run `/bind` in a chat to attach.)_" + : bindings.map((b) => `- \`${b.chat_id}\``).join("\n"); + const active = workspace.active_repo + ? `\`${workspace.active_repo}\`${ + workspace.active_branch ? ` @ \`${workspace.active_branch}\`` : "" + }` + : "_(unset)_"; + const lines = [ + `# Workspace: ${workspace.name}`, + "", + `- **ID**: \`${workspace.id}\``, + `- **Path**: \`${workspace.path}\``, + `- **Active repo**: ${active}`, + `- **Created**: ${_formatTs(workspace.created_at)}`, + `- **Updated**: ${_formatTs(workspace.updated_at)}`, + "", + "## Bound chats", + "", + chatLines, + "", + "", + "", + ]; + if (!existsSync(workspace.path)) { + mkdirSync(workspace.path, { recursive: true }); + } + writeFileSync(join(workspace.path, META_FILE_NAME), lines.join("\n")); + } catch (err) { + this._logger.warn( + { err, workspace_id: workspace.id }, + "failed to write workspace meta file", + ); + } + } +} + +function _safeBasename(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + const idx = Math.max( + trimmed.lastIndexOf("/"), + trimmed.lastIndexOf("\\"), + ); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; +} + +function _formatTs(ms: number): string { + return dayjs(ms).format("YYYY-MM-DD HH:mm:ss"); } diff --git a/src/shared/config/paths.ts b/src/shared/config/paths.ts index a896020..5d679c4 100644 --- a/src/shared/config/paths.ts +++ b/src/shared/config/paths.ts @@ -38,11 +38,24 @@ export const uploads = join(workspace, "uploads"); export const outputs = join(workspace, "outputs"); /** - * Per-group workspace root container: `$AGENTARA_HOME/workspaces//`. + * Workspace root container: `$AGENTARA_HOME/workspaces//`. * `_default/` inside it is the fallback workspace for unbound groups. + * + * Each workspace directory is keyed by its stable `ws_xxx` id so the + * human-readable `name` stays a pure display label — it can be renamed + * at any time without moving files. `resolveWorkspacePathByName` stays + * for diagnostics; `resolveGroupWorkspacePath` is kept only for legacy + * bindings created before id-based paths existed and is normalized away + * at boot. */ export const workspaces = join(home, "workspaces"); export const default_workspace = join(workspaces, "_default"); +export function resolveWorkspacePathById(workspace_id: string) { + return join(workspaces, workspace_id); +} +export function resolveWorkspacePathByName(name: string) { + return join(workspaces, name); +} export function resolveGroupWorkspacePath(chat_id: string) { return join(workspaces, chat_id); } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 8be0f79..0642121 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -1 +1,2 @@ +export * from "./slugify-workspace-name"; export * from "./uuid"; diff --git a/src/shared/utils/slugify-workspace-name.ts b/src/shared/utils/slugify-workspace-name.ts new file mode 100644 index 0000000..fad8c03 --- /dev/null +++ b/src/shared/utils/slugify-workspace-name.ts @@ -0,0 +1,31 @@ +/** + * Turn an arbitrary string (a Feishu group name, or whatever the user typed + * into the `/setup` workspace-name input) into a safe directory name for + * `$AGENTARA_HOME/workspaces//`. + * + * Design: + * - Preserve CJK characters (common in group names) — keeping the directory + * human-readable matters more than strict ASCII. + * - Collapse any whitespace run into a single `-`. + * - Drop path separators (`/`, `\`), leading dots (would make the dir hidden + * or be interpreted as `.`/`..`), and any control characters. + * - Trim leading/trailing separators/whitespace. + * - Enforce a sensible length cap so shells/tools don't choke on the path. + * + * Returns an empty string if nothing usable survives; callers should fall + * back to a deterministic chat-id-based name in that case. + */ +export function slugifyWorkspaceName(raw: string): string { + if (!raw) return ""; + // eslint-disable-next-line no-control-regex + let out = raw.replace(/[\u0000-\u001F\u007F]/g, ""); + out = out.replace(/\s+/g, "-"); + out = out.replace(/[\\/]/g, "-"); + // Strip characters that are dangerous or awkward as directory names. + out = out.replace(/[<>:"|?*`$]/g, ""); + out = out.replace(/^[.\-_\s]+/, ""); + out = out.replace(/[.\-_\s]+$/, ""); + out = out.replace(/-{2,}/g, "-"); + if (out.length > 64) out = out.slice(0, 64).replace(/-+$/, ""); + return out; +} diff --git a/src/shared/workspaces/types/group-workspace.ts b/src/shared/workspaces/types/group-workspace.ts index 62de14c..0be8a6c 100644 --- a/src/shared/workspaces/types/group-workspace.ts +++ b/src/shared/workspaces/types/group-workspace.ts @@ -1,22 +1,32 @@ import { z } from "zod"; +import { Workspace } from "./workspace"; + /** - * A persisted per-group workspace binding. + * A persisted per-group workspace binding, flattened for callers. + * + * Each Feishu group that has been bound via `/bind` or `/setup` owns one row + * in `group_workspaces`, but the underlying workspace itself lives in the + * separate `workspaces` registry so multiple groups can reference the same + * directory by stable `workspace_id`. The store joins them and returns this + * flattened shape so callers don't need to know the split. * - * Each Feishu group that has been bound via `/bind` or `/setup` owns one row. - * The workspace directory is always `$AGENTARA_HOME/workspaces//` - * and may host multiple cloned repos as first-level subdirectories. The - * active `(repo, branch)` pointer is what `_handleInboundMessageTask` reads - * to decide cwd and `DEV_ASSETS_PRIMARY_REPO` env extras. + * `active_repo`/`active_branch` come from the workspace row — groups sharing + * a workspace see the same active state because only one `.git/HEAD` exists + * per cloned repo. `created_at`/`updated_at` are the binding timestamps. */ export const GroupWorkspace = z.object({ /** Feishu chat id. */ chat_id: z.string(), - /** Absolute path of the workspace root. */ + /** Stable workspace id. */ + workspace_id: z.string(), + /** Human-readable workspace name (from workspaces). */ + workspace_name: z.string(), + /** Absolute path of the workspace root (from workspaces). */ workspace_path: z.string(), - /** Basename of the active repo under the workspace root, or null. */ + /** Basename of the active repo (from workspaces), or null. */ active_repo: z.string().nullable(), - /** Git branch the active repo should be on at dispatch, or null. */ + /** Git branch the active repo should be on (from workspaces), or null. */ active_branch: z.string().nullable(), /** Epoch ms when the binding was created. */ created_at: z.number(), @@ -24,3 +34,8 @@ export const GroupWorkspace = z.object({ updated_at: z.number(), }); export interface GroupWorkspace extends z.infer {} + +export const WorkspaceBinding = GroupWorkspace.extend({ + workspace: Workspace.optional(), +}); +export interface WorkspaceBinding extends z.infer {} diff --git a/src/shared/workspaces/types/index.ts b/src/shared/workspaces/types/index.ts index a968386..87e68f8 100644 --- a/src/shared/workspaces/types/index.ts +++ b/src/shared/workspaces/types/index.ts @@ -1 +1,2 @@ export * from "./group-workspace"; +export * from "./workspace"; diff --git a/src/shared/workspaces/types/workspace.ts b/src/shared/workspaces/types/workspace.ts new file mode 100644 index 0000000..019e389 --- /dev/null +++ b/src/shared/workspaces/types/workspace.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +/** + * A reusable workspace entity with a stable id. + * + * The `name` remains human-readable for operators, while `id` is what other + * groups can use to bind to the exact same workspace directory later. + * + * `active_repo`/`active_branch` live on the workspace (not the binding) + * because only one `.git/HEAD` exists per cloned repo — the "active" + * state is a property of the directory on disk, shared across all groups + * bound to the workspace. + */ +export const Workspace = z.object({ + /** Stable workspace id. */ + id: z.string(), + /** Human-readable workspace name. */ + name: z.string(), + /** Absolute path of the workspace root. */ + path: z.string(), + /** Basename of the currently focused repo under `path`, or null. */ + active_repo: z.string().nullable(), + /** Git branch to check out in the active repo on dispatch, or null. */ + active_branch: z.string().nullable(), + /** Epoch ms when the workspace was created. */ + created_at: z.number(), + /** Epoch ms when the workspace was last updated. */ + updated_at: z.number(), +}); +export interface Workspace extends z.infer {} From 875ebecc476582da8be7248c1afbec8bec3b2e5f Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 15:17:27 +0800 Subject: [PATCH 15/69] feat(feishu): slash commands bypass @-mention while whitelist applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators should be able to run quick commands like `/setup` and `/bind` in a group chat without having to @-mention the bot first — typing a slash is already an explicit-enough intent signal. The sender whitelist still applies, so random users in the room can't invoke commands. Detection is a narrow peek at raw text-type messages: `/^\/[a-zA-Z]/` on the trimmed content. Post/image/file messages never qualify, and parse failures silently fall back to the old mention-enforced path. --- .../feishu/messaging/message-channel.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index a11a9ff..1e16238 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -822,7 +822,15 @@ export class FeishuMessageChannel !this._allowedUserOpenIds || (senderOpenId != null && this._allowedUserOpenIds.has(senderOpenId)); - const mentionEnforced = this._requireMention && chatType === "group"; + // Slash commands (e.g. `/setup`, `/bind`) intentionally bypass the + // @-mention requirement — operators should be able to run them with a + // single keystroke in the chat bar. The sender whitelist still applies. + const isSlashCommand = _peekSlashCommand( + messageType, + receivedMessage.content, + ); + const mentionEnforced = + this._requireMention && chatType === "group" && !isSlashCommand; const isBotMentioned = !!this._botOpenId && !!mentions?.some((m) => m.id?.open_id === this._botOpenId); @@ -836,6 +844,7 @@ export class FeishuMessageChannel message_type: messageType, sender_open_id: senderOpenId, bot_mentioned: isBotMentioned, + slash_command: isSlashCommand, passed: isAllowedSender && mentionOk, }, "inbound message", @@ -1052,3 +1061,20 @@ export class FeishuMessageChannel } } } + +/** + * Cheap check for whether an inbound Feishu message looks like a slash + * command, so we can skip the @-mention requirement for those. We only + * inspect raw `text` content — post/image/file messages are never + * considered slash commands. Parsing failures → treat as non-slash. + */ +function _peekSlashCommand(type: string, content: string): boolean { + if (type !== "text") return false; + try { + const json = JSON.parse(content) as { text?: unknown }; + const text = typeof json.text === "string" ? json.text.trimStart() : ""; + return /^\/[a-zA-Z]/.test(text); + } catch { + return false; + } +} From c7b45c82de48169d26317b8a367a920fba9e226e Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 15:35:20 +0800 Subject: [PATCH 16/69] feat(workspace): git sync infrastructure + /switch flow + P2P gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related threads of workspace-lifecycle work that all land together because they touch the same command and flow files. ## Git sync - New `src/kernel/workspaces/git-sync.ts` with `syncWorkspace`, `listRepoSyncState`, and `formatAheadBehind` helpers. Sync does `git fetch --prune origin` + `git pull --ff-only` per repo, refuses to touch dirty or diverged trees, and returns a per-repo status enum. Timeout-bounded so a flaky network can't hang session start. - `/sync` command exposes it to operators as a manual refresh. - Kernel fires `syncWorkspace` fire-and-forget on the first message of a new session (bound chats only) so the agent starts on fresh code. - Setup flow adds `git fetch --prune` before checkout on already-cloned repos — necessary for switching to a branch that was pushed after the initial clone. Also attempts a best-effort ff-only pull after a successful checkout so re-running /setup without changing branch still picks up new commits. ## /status and formatting - `/status` now lists each cloned repo as `\`name branch\`` with an optional `↑a ↓b` suffix and a `•` dirty marker; the active repo is still flagged with `← 活跃`. Dropped the `name @ branch` shape across /bind, setup-flow, and the workspace meta file. - `/status` points users at `/sync` when there are repos to refresh. ## /switch flow + P2P gating - New `/switch` interactive card (switch-card.ts / switch-flow.ts): pick from all known workspaces, or clear the binding. Works in both group chats and P2P. - `UserMessage` carries `chat_type` so command gates can distinguish group vs P2P. `/bind`, `/unbind`, `/clone`, `/checkout`, and `/setup` reject P2P with copy pointing at `/switch`; `/status`, `/ls`, `/sync` remain usable in P2P. --- .../feishu/messaging/message-channel.ts | 11 + src/kernel/commands/handlers.ts | 155 ++++++- src/kernel/kernel.ts | 68 ++- src/kernel/setup/setup-flow.ts | 50 +- src/kernel/setup/switch-card.ts | 186 ++++++++ src/kernel/setup/switch-flow.ts | 271 +++++++++++ src/kernel/workspaces/git-sync.ts | 426 ++++++++++++++++++ src/kernel/workspaces/index.ts | 1 + src/kernel/workspaces/store.ts | 6 +- src/shared/messaging/types/message.ts | 6 + 10 files changed, 1151 insertions(+), 29 deletions(-) create mode 100644 src/kernel/setup/switch-card.ts create mode 100644 src/kernel/setup/switch-flow.ts create mode 100644 src/kernel/workspaces/git-sync.ts diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 1e16238..1e09064 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -866,12 +866,23 @@ export class FeishuMessageChannel } const session_id = this._resolveSessionId(chatId, threadId); + // Normalize Feishu's chat_type into the shared enum so command handlers + // can gate on group-vs-P2P without reaching back into provider details. + // Feishu emits `"p2p"` (not `"single"`) for 1:1 chats — accept both to be + // robust against SDK version drift. + const normalizedChatType: "group" | "single" | undefined = + chatType === "group" + ? "group" + : chatType === "p2p" || chatType === "single" + ? "single" + : undefined; const userMessage: UserMessage = { id: messageId, session_id, role: "user", channel_id: this.id, chat_id: chatId, + chat_type: normalizedChatType, thread_id: threadId, sender_open_id: senderOpenId, content: [ diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 9821345..c77af1f 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -1,6 +1,13 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; +import { + formatAheadBehind, + listRepoSyncState, + syncWorkspace, + type RepoSyncResult, +} from "@/kernel/workspaces"; + import type { CommandContext, CommandHandler } from "./types"; /** @@ -21,6 +28,21 @@ function requireChatId(ctx: CommandContext): string | null { return chatId; } +/** + * Gate for commands that may create a workspace or mutate repo state. + * P2P (`chat_type === "single"`) is intentionally kept read/reuse-only — + * it can browse via `/status` / `/ls` and rebind via `/switch`, but it + * shouldn't spawn new workspaces or kick off clones. Returns the chat_id + * when the command may proceed, or null when it should be rejected; the + * caller picks the right error copy. + */ +function requireGroupChat(ctx: CommandContext): string | null { + const chatId = ctx.message.chat_id; + if (!chatId) return null; + if (ctx.message.chat_type === "single") return null; + return chatId; +} + async function execGit( args: string[], cwd: string, @@ -42,8 +64,13 @@ const bindHandler: CommandHandler = { name: "bind", description: "/bind [workspace-id] — 绑定当前群到一个 workspace;传 id 时复用已有空间", async execute(ctx) { - const chatId = requireChatId(ctx); - if (!chatId) return "❌ /bind 仅在飞书群内可用。"; + const chatId = requireGroupChat(ctx); + if (!chatId) { + if (ctx.message.chat_type === "single") { + return "❌ /bind 仅在飞书群内可用;单聊请使用 `/switch` 挑选已有 workspace。"; + } + return "❌ /bind 仅在飞书群内可用。"; + } const [workspaceId] = ctx.args; if (workspaceId) { const workspace = ctx.workspaceStore.getWorkspace(workspaceId); @@ -61,9 +88,9 @@ const bindHandler: CommandHandler = { "group rebound to existing workspace", ); const activeLine = binding.active_repo - ? `- 活跃仓库:\`${binding.active_repo}\`${ - binding.active_branch ? ` @ \`${binding.active_branch}\`` : "" - }` + ? `- 活跃仓库:\`${binding.active_repo}${ + binding.active_branch ? " " + binding.active_branch : "" + }\`` : "- 活跃仓库:(未设置,可用 `/setup` 配置)"; return [ `✅ 当前群已绑定到已有 workspace:\`${binding.workspace_name}\``, @@ -99,8 +126,13 @@ const unbindHandler: CommandHandler = { name: "unbind", description: "/unbind — 清除当前群的绑定(回退到默认 workspace)", async execute(ctx) { - const chatId = requireChatId(ctx); - if (!chatId) return "❌ /unbind 仅在飞书群内可用。"; + const chatId = requireGroupChat(ctx); + if (!chatId) { + if (ctx.message.chat_type === "single") { + return "❌ /unbind 仅在飞书群内可用;单聊请使用 `/switch` 并选择「取消绑定」。"; + } + return "❌ /unbind 仅在飞书群内可用。"; + } const removed = ctx.workspaceStore.deleteBinding(chatId); if (!removed) return "ℹ️ 当前群未绑定。"; return "✅ 群绑定已清除。后续消息将使用默认 workspace。"; @@ -119,24 +151,47 @@ const statusHandler: CommandHandler = { ].join("\n"); } const resolution = ctx.workspaceStore.resolve(chatId); + const isP2P = ctx.message.chat_type === "single"; const lines: string[] = []; if (!resolution.binding) { - lines.push("ℹ️ 当前群 **未绑定**。"); + lines.push( + isP2P + ? "ℹ️ 当前单聊 **未绑定** 任何 workspace。" + : "ℹ️ 当前群 **未绑定**。", + ); lines.push(`默认 workspace:\`${resolution.cwd}\``); - lines.push("使用 `/bind` 创建一个新 workspace,或 `/bind ` 复用已有空间。"); + if (isP2P) { + lines.push("使用 `/switch` 挑一个已有 workspace 绑定到单聊。"); + } else { + lines.push( + "使用 `/setup` 初始化,或 `/bind ` 复用已有空间,或 `/switch` 交互式挑选。", + ); + } return lines.join("\n"); } lines.push("**当前群绑定:**"); lines.push(`- Workspace ID:\`${resolution.binding.workspace_id}\``); lines.push(`- Workspace 名称:\`${resolution.binding.workspace_name}\``); lines.push(`- Workspace 路径:\`${resolution.binding.workspace_path}\``); - lines.push(`- 活跃仓库:\`${resolution.binding.active_repo ?? "(未设置)"}\``); - lines.push(`- 活跃分支:\`${resolution.binding.active_branch ?? "(未设置)"}\``); + const activeRepo = resolution.binding.active_repo; + const activeBranch = resolution.binding.active_branch; + const activeLabel = activeRepo + ? `\`${activeRepo}${activeBranch ? " " + activeBranch : ""}\`` + : "(未设置)"; + lines.push(`- 活跃仓库:${activeLabel}`); lines.push("- 复用到其他群:`/bind `"); - const repos = listRepoBasenames(resolution.binding.workspace_path); - if (repos.length > 0) { + const repoStates = listRepoSyncState(resolution.binding.workspace_path); + if (repoStates.length > 0) { lines.push("", "**Workspace 中已克隆的仓库:**"); - for (const name of repos) lines.push(`- \`${name}\``); + for (const s of repoStates) { + const primary = s.name === activeRepo ? " ← 活跃" : ""; + const ahead_behind = formatAheadBehind(s.ahead, s.behind); + const dirty = s.dirty ? " •" : ""; + const label = s.branch ? `${s.name} ${s.branch}` : s.name; + const suffix = ahead_behind ? ` ${ahead_behind}` : ""; + lines.push(`- \`${label}\`${suffix}${dirty}${primary}`); + } + lines.push("", "_使用 `/sync` 拉取远端更新。_"); } else { lines.push("", "_Workspace 还没有克隆任何仓库。_"); } @@ -147,6 +202,27 @@ const statusHandler: CommandHandler = { }, }; +const syncHandler: CommandHandler = { + name: "sync", + description: "/sync — 对当前 workspace 下的每个仓库 fetch + 快进拉取", + async execute(ctx) { + const chatId = requireChatId(ctx); + const resolution = ctx.workspaceStore.resolve(chatId ?? null); + if (!resolution.binding && chatId) { + return "ℹ️ 当前群未绑定 workspace;先执行 `/setup` 或 `/bind` 再 `/sync`。"; + } + const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; + ctx.logger.info({ workspace_path: workspacePath }, "manual /sync requested"); + const results = await syncWorkspace(workspacePath, { pull: true }); + if (results.length === 0) { + return `_\`${workspacePath}\` 下没有 git 仓库。_`; + } + const lines = [`**\`${workspacePath}\` 同步结果:**`]; + for (const r of results) lines.push(_formatSyncLine(r)); + return lines.join("\n"); + }, +}; + const lsHandler: CommandHandler = { name: "ls", description: "/ls — 列出当前群 workspace 下的所有仓库", @@ -172,8 +248,13 @@ const cloneHandler: CommandHandler = { name: "clone", description: "/clone [别名] — 将仓库克隆到当前群的 workspace", async execute(ctx) { - const chatId = requireChatId(ctx); - if (!chatId) return "❌ /clone 仅在飞书群内可用。"; + const chatId = requireGroupChat(ctx); + if (!chatId) { + if (ctx.message.chat_type === "single") { + return "❌ /clone 仅在飞书群内可用;单聊请先 `/switch` 到目标 workspace,在群里执行克隆。"; + } + return "❌ /clone 仅在飞书群内可用。"; + } const [url, explicitName] = ctx.args; if (!url) return "用法:`/clone [别名]`"; const resolution = ctx.workspaceStore.resolve(chatId); @@ -203,8 +284,13 @@ const checkoutHandler: CommandHandler = { name: "checkout", description: "/checkout <分支> — 切换当前活跃仓库的分支", async execute(ctx) { - const chatId = requireChatId(ctx); - if (!chatId) return "❌ /checkout 仅在飞书群内可用。"; + const chatId = requireGroupChat(ctx); + if (!chatId) { + if (ctx.message.chat_type === "single") { + return "❌ /checkout 仅在飞书群内可用;单聊共享 workspace 的分支由群侧管理。"; + } + return "❌ /checkout 仅在飞书群内可用。"; + } const [branch] = ctx.args; if (!branch) return "用法:`/checkout <分支>`"; const resolution = ctx.workspaceStore.resolve(chatId); @@ -238,7 +324,8 @@ export const helpHandler: CommandHandler = { ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), "- /help — 显示本消息", "- /stop — 取消当前 session 正在执行的任务", - "- /setup — 打开交互卡片,创建或更新 workspace,并设置主仓库/分支", + "- /setup — 打开交互卡片,创建或更新 workspace,并设置主仓库/分支(仅群聊)", + "- /switch — 打开交互卡片,切换当前会话到已有 workspace(群聊 & 单聊)", ].join("\n"); }, }; @@ -247,11 +334,41 @@ export const BUILTIN_COMMANDS: CommandHandler[] = [ bindHandler, unbindHandler, statusHandler, + syncHandler, lsHandler, cloneHandler, checkoutHandler, ]; +function _formatSyncLine(r: RepoSyncResult): string { + const label = r.branch ? `\`${r.name} ${r.branch}\`` : `\`${r.name}\``; + const ab = formatAheadBehind(r.ahead, r.behind); + const abSuffix = ab ? ` ${ab}` : ""; + switch (r.status) { + case "up_to_date": + return `- ✅ ${label}${abSuffix}`; + case "fast_forwarded": + return ( + `- ⬇️ ${label} 已快进${abSuffix}` + + (r.before_sha && r.after_sha + ? `:${r.before_sha} → ${r.after_sha}` + : "") + ); + case "no_upstream": + return `- ℹ️ ${label} 无 upstream`; + case "detached": + return `- ⚠️ ${r.name} 处于 detached HEAD${abSuffix}`; + case "skipped_dirty": + return `- 🚧 ${label}${abSuffix} 工作区有未提交改动,跳过拉取`; + case "skipped_diverged": + return `- 🚧 ${label}${abSuffix} 本地与 upstream 分叉,跳过快进拉取`; + case "fetch_failed": + return `- ❌ ${label} fetch 失败${r.detail ? `:${r.detail}` : ""}`; + case "pull_failed": + return `- ❌ ${label}${abSuffix} pull 失败${r.detail ? `:${r.detail}` : ""}`; + } +} + function listRepoBasenames(workspacePath: string): string[] { if (!existsSync(workspacePath)) return []; try { diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 9577665..16b10bd 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -18,9 +18,10 @@ import { MultiChannelMessageGateway } from "./messaging"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; import { SetupFlow } from "./setup/setup-flow"; +import { SwitchFlow } from "./setup/switch-flow"; import { TaskDispatcher } from "./tasking"; import * as taskingSchema from "./tasking/data"; -import { GroupWorkspaceStore } from "./workspaces"; +import { GroupWorkspaceStore, syncWorkspace } from "./workspaces"; /** * The kernel is the main entry point for the agentara application. @@ -37,6 +38,7 @@ class Kernel { private _commandRegistry!: CommandRegistry; private _feishuChannels = new Map(); private _setupFlow!: SetupFlow; + private _switchFlow!: SwitchFlow; constructor() { this._initDatabase(); @@ -46,6 +48,7 @@ class Kernel { this._initTaskDispatcher(); this._initMessageGateway(); this._initSetupFlow(); + this._initSwitchFlow(); this._initServer(); } @@ -142,6 +145,13 @@ class Kernel { }); } + private _initSwitchFlow(): void { + this._switchFlow = new SwitchFlow({ + workspaceStore: this._workspaceStore, + feishuChannels: this._feishuChannels, + }); + } + /** * Start the kernel. */ @@ -179,12 +189,28 @@ class Kernel { return; } + // Handle /switch command (kernel-owned — interactive card). Available in + // both group chats and P2P since switching binding only touches metadata. + if (text === "/switch") { + await this._switchFlow.start(message); + return; + } + // Try gateway-level slash commands before dispatching to the LLM. if (text.startsWith("/")) { const handled = await this._tryHandleCommand(message, text); if (handled) return; } + // On the first message of a new session, kick off a best-effort + // fetch + ff-pull across the workspace so the agent sees latest remote + // state. Fire-and-forget: the pull is atomic at the git level, the + // agent dispatch queue gives it a head start, and we don't want a + // flaky network to block the user's first turn. + if (isSessionStart && message.chat_id) { + this._autoSyncOnSessionStart(message.chat_id); + } + const task: InboundMessageTaskPayload = { type: "inbound_message", message, @@ -192,6 +218,37 @@ class Kernel { await this._taskDispatcher.dispatch(message.session_id, task); }; + private _autoSyncOnSessionStart(chatId: string): void { + const resolution = this._workspaceStore.resolve(chatId); + if (!resolution.binding) return; + const workspacePath = resolution.binding.workspace_path; + syncWorkspace(workspacePath, { pull: true, timeout_ms: 15_000 }) + .then((results) => { + const ff = results.filter((r) => r.status === "fast_forwarded"); + if (ff.length > 0) { + this._logger.info( + { + chat_id: chatId, + workspace: workspacePath, + fast_forwarded: ff.map((r) => ({ + repo: r.name, + branch: r.branch, + before: r.before_sha, + after: r.after_sha, + })), + }, + "session-start auto-sync fast-forwarded", + ); + } + }) + .catch((err) => { + this._logger.warn( + { err, chat_id: chatId, workspace: workspacePath }, + "session-start auto-sync failed", + ); + }); + } + private _tryHandleCommand = async ( message: UserMessage, text: string, @@ -284,14 +341,19 @@ class Kernel { }; /** - * Route card-action callbacks by the `action_name` discriminator. Right now - * only `/setup` produces callbacks; unknown actions are logged and dropped. + * Route card-action callbacks by the `action_name` discriminator. Each + * interactive flow owns its own `action_name`; unknown actions are logged + * and dropped. */ private _handleCardAction = async (payload: CardActionPayload) => { if (payload.action_name === "setup_submit") { await this._setupFlow.handleSubmit(payload); return; } + if (payload.action_name === "switch_submit") { + await this._switchFlow.handleSubmit(payload); + return; + } this._logger.warn( { action_name: payload.action_name, message_id: payload.message_id }, "unhandled card action", diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index c981086..e28503f 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -98,6 +98,17 @@ export class SetupFlow { await this._replyText(message, "❌ /setup 仅在飞书群内可用。"); return; } + // P2P is intentionally kept reuse-only — creating workspaces from single + // chats would produce stray rows keyed to every user's P2P chat_id and + // make the workspace list hard to reason about. Point users at /switch + // instead, which lets them bind to any already-existing workspace. + if (message.chat_type === "single") { + await this._replyText( + message, + "❌ /setup 仅在飞书群内可用;单聊请使用 `/switch` 挑选一个已有 workspace。", + ); + return; + } const catalog = loadPredefinedRepos(); if (catalog.length === 0) { await this._replyText( @@ -224,7 +235,7 @@ export class SetupFlow { buildSetupResultCard( `⏳ 正在初始化 \`${selections.map((s) => s.name).join("、")}\`…`, selections.map( - (s) => `- \`${s.name}\` @ \`${s.branch}\``, + (s) => `- \`${s.name} ${s.branch}\``, ), ), "pending-state", @@ -275,7 +286,7 @@ export class SetupFlow { ...results.map(_formatResultLine), ]; const summary = activeRepo && activeBranch - ? `✅ 初始化完成,主仓库 \`${activeRepo}\` @ \`${activeBranch}\`。` + ? `✅ 初始化完成,主仓库 \`${activeRepo} ${activeBranch}\`。` : "⚠️ workspace 已创建,但这次没有成功设置主仓库。"; await this._tryUpdateCard( channel, @@ -430,6 +441,20 @@ export class SetupFlow { detail: clone.stderr || clone.stdout, }; } + } else { + // Fetch so `git checkout ` can find branches pushed after + // the initial clone. Fetch failures are non-fatal — if the user only + // wants to switch between already-known branches, offline is fine. + const fetch = await _execGit( + ["fetch", "--prune", "origin"], + targetPath, + ); + if (!fetch.ok) { + this._logger.warn( + { repo: sel.name, stderr: fetch.stderr }, + "git fetch failed before checkout; continuing with stale refs", + ); + } } const co = await _execGit(["checkout", sel.branch], targetPath); @@ -450,6 +475,23 @@ export class SetupFlow { }; } + // After a successful checkout on an already-cloned repo, try a + // fast-forward pull so users who re-run /setup without changing the + // branch still get the latest commits. `--ff-only` refuses on dirty + // trees or divergence, which keeps this safe to run unconditionally. + if (alreadyCloned) { + const pull = await _execGit( + ["pull", "--ff-only", "--no-rebase"], + targetPath, + ); + if (!pull.ok) { + this._logger.info( + { repo: sel.name, stderr: pull.stderr }, + "ff-only pull skipped/failed; leaving at current commit", + ); + } + } + return { name: sel.name, branch: sel.branch, @@ -489,9 +531,9 @@ function _isTruthyChecker(v: unknown): boolean { function _formatResultLine(r: RepoResult): string { switch (r.status) { case "cloned": - return `- ✅ \`${r.name}\` @ \`${r.branch}\` 已克隆`; + return `- ✅ \`${r.name} ${r.branch}\` 已克隆`; case "exists": - return `- ℹ️ \`${r.name}\` 已存在,已切换到 \`${r.branch}\``; + return `- ℹ️ \`${r.name} ${r.branch}\` 已存在`; case "checkout_failed": return ( `- ⚠️ \`${r.name}\` 已克隆,分支 \`${r.branch}\` 不可切换,` + diff --git a/src/kernel/setup/switch-card.ts b/src/kernel/setup/switch-card.ts new file mode 100644 index 0000000..b481c84 --- /dev/null +++ b/src/kernel/setup/switch-card.ts @@ -0,0 +1,186 @@ +import type { Workspace } from "@/shared"; + +import type { + ButtonElement, + Card, + Element, + FormElement, + MarkdownElement, + SelectStaticElement, +} from "../../community/feishu/messaging/types"; + +/** + * Shared field names used by both the card renderer and the submit handler. + * Keep them in one place so the two sides cannot drift apart. + */ +export const SWITCH_FIELD = { + workspaceId: "workspace_id", +} as const; + +/** + * Sentinel value emitted by the "detach" option. Not a real workspace id — + * picking it clears the binding for the current chat and falls back to the + * default workspace. + */ +export const SWITCH_DETACH_VALUE = "__detach__"; + +/** + * Summary of the current binding, rendered above the selector so the user + * can see what they're switching away from before picking. + */ +export interface CurrentBindingSummary { + workspace_id: string; + workspace_name: string; + workspace_path: string; + active_repo?: string | null; + active_branch?: string | null; +} + +export interface SwitchCardOptions { + /** All known workspaces, sorted by caller. */ + workspaces: Workspace[]; + /** Current binding for this chat; preselects the dropdown + shows summary. */ + current?: CurrentBindingSummary; +} + +/** + * Build the `/switch` card. + * + * Layout: + * - Header + * - Current-binding summary (markdown) — omitted when chat has no binding + * - Form body: + * - select_static with one option per workspace + a trailing "detach" option + * - Submit button ("切换") + * + * Pending correlation happens on the kernel side via `message_id`, so the + * card carries no id of its own. + */ +export function buildSwitchCard(options: SwitchCardOptions): Card { + const { workspaces, current } = options; + + const header: MarkdownElement = { + tag: "markdown", + content: [ + "**🔀 切换当前会话的 workspace**", + "", + "从下面的列表里挑一个已有 workspace;选「取消绑定」则回到默认 workspace。", + ].join("\n"), + }; + + const body: Element[] = [header]; + + if (current) { + body.push({ + tag: "markdown", + content: [ + "**当前绑定:**", + `- 名称:\`${current.workspace_name}\``, + `- ID:\`${current.workspace_id}\``, + `- 活跃仓库:\`${current.active_repo ?? "(未设置)"}\``, + `- 活跃分支:\`${current.active_branch ?? "(未设置)"}\``, + ].join("\n"), + }); + } else { + body.push({ + tag: "markdown", + content: + "_当前会话还没有绑定任何 workspace,正在使用默认 workspace。_", + }); + } + + const select = _buildWorkspaceSelect(workspaces, current?.workspace_id); + const submit = _buildSubmitButton(); + const form: FormElement = { + tag: "form", + name: "switch_form", + elements: [select, submit], + }; + body.push(form); + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: "🔀 切换 workspace" }, + }, + body: { elements: body }, + }; +} + +/** + * Result card rendered after the submit handler finishes. Replaces the + * original card in place via `updateRawCard`. + */ +export function buildSwitchResultCard(summary: string, detail: string[] = []): Card { + const elements: Element[] = [ + { tag: "markdown", content: summary }, + ]; + if (detail.length > 0) { + elements.push({ tag: "markdown", content: detail.join("\n") }); + } + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: summary.slice(0, 80) }, + }, + body: { elements }, + }; +} + +function _buildWorkspaceSelect( + workspaces: Workspace[], + preselected?: string, +): SelectStaticElement { + // Feishu's select_static has no "empty" state — when there are no + // workspaces we still render a single disabled-feeling placeholder option + // so the card is valid. Callers should prefer to skip the card entirely in + // that case, but we don't want the renderer to throw either way. + const options = workspaces.map((ws) => ({ + text: { + tag: "plain_text" as const, + content: `${ws.name} (${ws.id})`, + }, + value: ws.id, + })); + options.push({ + text: { + tag: "plain_text" as const, + content: "🔌 取消绑定(回到默认 workspace)", + }, + value: SWITCH_DETACH_VALUE, + }); + + const initial = + preselected && workspaces.some((ws) => ws.id === preselected) + ? preselected + : options[0]?.value; + + return { + tag: "select_static", + name: SWITCH_FIELD.workspaceId, + placeholder: { tag: "plain_text", content: "选择一个 workspace" }, + initial_option: initial, + options, + width: "fill", + }; +} + +function _buildSubmitButton(): ButtonElement { + // Uses `action_type: "form_submit"` — Feishu's form container needs at + // least one submit-type button; mixing `behaviors: [{type:"callback"}]` + // here would cause the container to reject the button as non-submit + // ("there is no submit button in the form container"). + return { + tag: "button", + name: "switch_submit", + text: { tag: "plain_text", content: "✅ 切换" }, + type: "primary", + action_type: "form_submit", + }; +} diff --git a/src/kernel/setup/switch-flow.ts b/src/kernel/setup/switch-flow.ts new file mode 100644 index 0000000..283c299 --- /dev/null +++ b/src/kernel/setup/switch-flow.ts @@ -0,0 +1,271 @@ +import type { Logger } from "@/shared"; +import { + createLogger, + type CardActionPayload, + type UserMessage, +} from "@/shared"; + +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; +import type { GroupWorkspaceStore } from "../workspaces"; + +import { + buildSwitchCard, + buildSwitchResultCard, + SWITCH_DETACH_VALUE, + SWITCH_FIELD, +} from "./switch-card"; + +/** + * In-memory pending state for a `/switch` card that has been sent but not + * yet submitted. Dropped on kernel restart — expired cards surface a clear + * error back to the user instead of being silently honored. + */ +interface PendingSwitch { + chat_id: string; + initiator_open_id: string; + /** Snapshot of valid workspace ids at card-render time, for validation on submit. */ + valid_workspace_ids: Set; + created_at: number; +} + +/** + * Stateful orchestrator for the `/switch` interactive flow. + * + * Lifecycle: + * 1. `start(message)` lists all known workspaces, renders the card, and + * remembers the id snapshot keyed by the outbound message id. + * 2. The kernel routes a `card:action` with `action_name === "switch_submit"` + * to `handleSubmit(payload)`. + * 3. The handler applies the binding change (switch or detach) and replaces + * the card in place with a result card. + * + * Unlike `/setup`, `/switch` is available in both group chats and single + * chats (P2P) — its only job is re-pointing the chat's binding at an + * already-existing workspace, no filesystem mutation. + */ +export class SwitchFlow { + private readonly _logger: Logger = createLogger("switch-flow"); + private readonly _workspaceStore: GroupWorkspaceStore; + private readonly _feishuChannels: Map; + private readonly _pending = new Map(); + + constructor(deps: { + workspaceStore: GroupWorkspaceStore; + feishuChannels: Map; + }) { + this._workspaceStore = deps.workspaceStore; + this._feishuChannels = deps.feishuChannels; + } + + /** + * Entry point invoked from `kernel._handleInboundMessage` when the inbound + * text is `/switch`. Works in both group and P2P Feishu chats since the + * binding is keyed purely on `chat_id`. + */ + async start(message: UserMessage): Promise { + const chatId = message.chat_id; + if (!chatId || !message.channel_id) { + await this._replyText( + message, + "❌ /switch 需要飞书会话上下文(群聊或单聊均可)。", + ); + return; + } + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) { + await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); + return; + } + + const workspaces = this._workspaceStore.listWorkspaces(); + if (workspaces.length === 0) { + await this._replyText( + message, + [ + "ℹ️ 还没有任何 workspace。", + "请先在某个群里执行 `/setup` 创建一个,然后回到这里 `/switch` 挑选。", + ].join("\n"), + ); + return; + } + + const current = this._workspaceStore.getBinding(chatId); + const sortedWorkspaces = [...workspaces].sort((a, b) => + a.name.localeCompare(b.name), + ); + const card = buildSwitchCard({ + workspaces: sortedWorkspaces, + current: current + ? { + workspace_id: current.workspace_id, + workspace_name: current.workspace_name, + workspace_path: current.workspace_path, + active_repo: current.active_repo, + active_branch: current.active_branch, + } + : undefined, + }); + const cardMessageId = await channel.sendRawCard(chatId, card, { + replyTo: message.id, + }); + this._pending.set(cardMessageId, { + chat_id: chatId, + initiator_open_id: message.sender_open_id ?? "", + valid_workspace_ids: new Set(sortedWorkspaces.map((ws) => ws.id)), + created_at: Date.now(), + }); + this._logger.info( + { + chat_id: chatId, + card_message_id: cardMessageId, + workspace_count: sortedWorkspaces.length, + }, + "switch card sent", + ); + } + + /** + * Entry point invoked from the kernel's `card:action` listener when the + * payload's `action_name === "switch_submit"`. Looks up pending state by + * `payload.message_id`, validates, and applies the binding change. + */ + async handleSubmit(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id }, + "received switch card action for unknown channel", + ); + return; + } + const pending = this._pending.get(payload.message_id); + if (!pending) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSwitchResultCard("⚠️ 这张卡片已失效,请重新发送 `/switch`。"), + "expired", + ); + return; + } + if ( + pending.initiator_open_id && + payload.operator_open_id !== pending.initiator_open_id + ) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSwitchResultCard("🚫 这不是你的表单。"), + "non-initiator", + ); + return; + } + + this._pending.delete(payload.message_id); + + const rawPick = payload.form_value[SWITCH_FIELD.workspaceId]; + const pick = typeof rawPick === "string" ? rawPick : ""; + + if (pick === SWITCH_DETACH_VALUE) { + const removed = this._workspaceStore.deleteBinding(pending.chat_id); + const msg = removed + ? "✅ 已取消绑定,当前会话回到默认 workspace。" + : "ℹ️ 当前会话本来就没有绑定,回到默认 workspace。"; + await this._tryUpdateCard( + channel, + payload.message_id, + buildSwitchResultCard(msg), + "detached", + ); + this._logger.info( + { chat_id: pending.chat_id, removed }, + "switch card: detached", + ); + return; + } + + if (!pick || !pending.valid_workspace_ids.has(pick)) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSwitchResultCard( + "⚠️ 选择的 workspace 已不存在,请重新发送 `/switch`。", + ), + "invalid-selection", + ); + return; + } + + let binding; + try { + binding = this._workspaceStore.upsertBinding(pending.chat_id, { + workspace_id: pick, + }); + } catch (err) { + this._logger.error( + { err, chat_id: pending.chat_id, workspace_id: pick }, + "switch card: upsertBinding failed", + ); + await this._tryUpdateCard( + channel, + payload.message_id, + buildSwitchResultCard( + `❌ 绑定失败:${(err as Error).message}`, + ), + "upsert-failed", + ); + return; + } + + const summary = `✅ 已切换到 workspace \`${binding.workspace_name}\`。`; + const detail = [ + `- Workspace ID:\`${binding.workspace_id}\``, + `- 活跃仓库:\`${binding.active_repo ?? "(未设置)"}\``, + `- 活跃分支:\`${binding.active_branch ?? "(未设置)"}\``, + ]; + await this._tryUpdateCard( + channel, + payload.message_id, + buildSwitchResultCard(summary, detail), + "final-result", + ); + this._logger.info( + { chat_id: pending.chat_id, workspace_id: binding.workspace_id }, + "switch card: binding updated", + ); + } + + private async _tryUpdateCard( + channel: FeishuMessageChannel, + messageId: string, + card: ReturnType, + stage: string, + ): Promise { + try { + await channel.updateRawCard(messageId, card); + } catch (err) { + this._logger.error( + { err, stage, message_id: messageId }, + "updateRawCard failed", + ); + } + } + + private async _replyText( + message: UserMessage, + text: string, + ): Promise { + if (!message.channel_id) return; + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) return; + await channel.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }, + { streaming: false, replyInThread: false }, + ); + } +} diff --git a/src/kernel/workspaces/git-sync.ts b/src/kernel/workspaces/git-sync.ts new file mode 100644 index 0000000..cef8ed4 --- /dev/null +++ b/src/kernel/workspaces/git-sync.ts @@ -0,0 +1,426 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { createLogger } from "@/shared"; + +const _logger = createLogger("git-sync"); + +export interface RepoSyncState { + /** Repo directory name under the workspace root. */ + name: string; + /** Absolute path to the repo. */ + path: string; + /** Current branch; `undefined` for detached HEAD. */ + branch?: string; + /** Name of the upstream branch (e.g. `origin/master`), when configured. */ + upstream?: string; + /** Commits on the local branch not yet on upstream. */ + ahead: number; + /** Commits on upstream not yet on the local branch. */ + behind: number; + /** True when the working tree has uncommitted changes. */ + dirty: boolean; +} + +export type PullStatus = + /** `git fetch` + `git pull --ff-only` succeeded (may be a no-op if already up-to-date). */ + | "up_to_date" + | "fast_forwarded" + /** Fetch worked but pull was skipped because the tree was dirty or diverged. */ + | "skipped_dirty" + | "skipped_diverged" + /** No upstream configured — there's nothing to pull from. */ + | "no_upstream" + /** Detached HEAD — there's no branch to pull into. */ + | "detached" + /** The network / `git` process failed. */ + | "fetch_failed" + | "pull_failed"; + +export interface RepoSyncResult { + name: string; + path: string; + branch?: string; + /** Ahead/behind after the (attempted) sync, so /sync output reflects current truth. */ + ahead: number; + behind: number; + status: PullStatus; + /** Short commit SHAs before/after on status `fast_forwarded`. */ + before_sha?: string; + after_sha?: string; + /** Short human-readable detail on failure/skip paths. */ + detail?: string; +} + +export interface SyncOptions { + /** + * Whether to attempt `git pull --ff-only` after fetch. When false, only the + * fetch is performed and ahead/behind is refreshed without touching the + * working tree. Defaults to true. + */ + pull?: boolean; + /** + * Per-repo timeout budget in ms. Individual git processes get at most this + * long before we kill them and record a failure — keeps a flaky network + * from hanging session start forever. Defaults to 20_000. + */ + timeout_ms?: number; +} + +/** + * Fire a fetch + optional ff-only pull for every git repo under `workspacePath`. + * Safe to run concurrently with an active agent: fetch never touches the + * working tree, and ff-only pull is atomic and refuses to run on dirty / + * diverged repos. Returns one result per repo — callers decide how to + * format (e.g. /sync writes a summary; session-start logs silently). + */ +export async function syncWorkspace( + workspacePath: string, + options: SyncOptions = {}, +): Promise { + const repos = listRepos(workspacePath); + const results: RepoSyncResult[] = []; + for (const repo of repos) { + results.push(await syncRepo(repo.path, options)); + } + return results; +} + +/** + * Sync a single repo. See {@link syncWorkspace} for semantics. + */ +export async function syncRepo( + repoPath: string, + options: SyncOptions = {}, +): Promise { + const pull = options.pull !== false; + const timeout = options.timeout_ms ?? 20_000; + const name = _basename(repoPath); + const branch = await _readCurrentBranch(repoPath); + + const fetch = await _execGit( + ["fetch", "--prune", "origin"], + repoPath, + timeout, + ); + if (!fetch.ok) { + const state = await _readState(repoPath); + return { + name, + path: repoPath, + branch, + ahead: state.ahead, + behind: state.behind, + status: "fetch_failed", + detail: _compressErr(fetch.stderr || fetch.stdout), + }; + } + + if (!branch) { + const state = await _readState(repoPath); + return { + name, + path: repoPath, + ahead: state.ahead, + behind: state.behind, + status: "detached", + }; + } + + const upstream = await _readUpstream(repoPath); + if (!upstream) { + return { + name, + path: repoPath, + branch, + ahead: 0, + behind: 0, + status: "no_upstream", + }; + } + + const state = await _readState(repoPath); + + if (!pull) { + return { + name, + path: repoPath, + branch, + ahead: state.ahead, + behind: state.behind, + status: state.behind === 0 ? "up_to_date" : "up_to_date", + }; + } + + if (state.dirty) { + return { + name, + path: repoPath, + branch, + ahead: state.ahead, + behind: state.behind, + status: "skipped_dirty", + detail: "working tree has uncommitted changes", + }; + } + if (state.ahead > 0 && state.behind > 0) { + return { + name, + path: repoPath, + branch, + ahead: state.ahead, + behind: state.behind, + status: "skipped_diverged", + detail: `local has ${state.ahead} commit(s) not on upstream`, + }; + } + if (state.behind === 0) { + return { + name, + path: repoPath, + branch, + ahead: state.ahead, + behind: 0, + status: "up_to_date", + }; + } + + const before = await _readHeadSha(repoPath); + const pullRes = await _execGit( + ["pull", "--ff-only", "--no-rebase"], + repoPath, + timeout, + ); + if (!pullRes.ok) { + return { + name, + path: repoPath, + branch, + ahead: state.ahead, + behind: state.behind, + status: "pull_failed", + detail: _compressErr(pullRes.stderr || pullRes.stdout), + }; + } + const after = await _readHeadSha(repoPath); + const postState = await _readState(repoPath); + return { + name, + path: repoPath, + branch, + ahead: postState.ahead, + behind: postState.behind, + status: "fast_forwarded", + before_sha: before, + after_sha: after, + }; +} + +/** + * Read the sync state of every git repo under `workspacePath` without doing + * any network work. Used by `/status` to render the ahead/behind indicators + * from the most recent fetch's locally-known upstream refs. + */ +export function listRepoSyncState(workspacePath: string): RepoSyncState[] { + return listRepos(workspacePath).map((r) => { + const branch = _readCurrentBranchSync(r.path); + const upstream = _readUpstreamSync(r.path); + const ahead = upstream ? _readAheadSync(r.path) : 0; + const behind = upstream ? _readBehindSync(r.path) : 0; + const dirty = _readDirtySync(r.path); + return { + name: r.name, + path: r.path, + branch, + upstream, + ahead, + behind, + dirty, + }; + }); +} + +/** + * Render `↑a ↓b` for ahead/behind counts, omitting zero sides. Returns an + * empty string when both sides are zero. Callers usually concatenate this + * after a `repo branch` code block. + */ +export function formatAheadBehind(ahead: number, behind: number): string { + const parts: string[] = []; + if (ahead > 0) parts.push(`↑${ahead}`); + if (behind > 0) parts.push(`↓${behind}`); + return parts.join(" "); +} + +function listRepos( + workspacePath: string, +): Array<{ name: string; path: string }> { + if (!existsSync(workspacePath)) return []; + try { + return readdirSync(workspacePath) + .filter((name) => !name.startsWith(".")) + .map((name) => ({ name, path: join(workspacePath, name) })) + .filter((r) => { + try { + return statSync(r.path).isDirectory() && existsSync(join(r.path, ".git")); + } catch { + return false; + } + }) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return []; + } +} + +async function _execGit( + args: string[], + cwd: string, + timeoutMs: number, +): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => { + try { + proc.kill(); + } catch { + // ignore — process may already be gone + } + }, timeoutMs); + try { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { + ok: code === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + code, + }; + } catch (err) { + _logger.warn({ err, args, cwd }, "git exec threw"); + return { ok: false, stdout: "", stderr: String(err), code: -1 }; + } finally { + clearTimeout(timer); + } +} + +async function _readCurrentBranch(repoPath: string): Promise { + const res = await _execGit( + ["rev-parse", "--abbrev-ref", "HEAD"], + repoPath, + 5000, + ); + if (!res.ok) return undefined; + const out = res.stdout; + return out && out !== "HEAD" ? out : undefined; +} + +async function _readUpstream(repoPath: string): Promise { + const res = await _execGit( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + repoPath, + 5000, + ); + return res.ok && res.stdout ? res.stdout : undefined; +} + +async function _readHeadSha(repoPath: string): Promise { + const res = await _execGit(["rev-parse", "--short=12", "HEAD"], repoPath, 5000); + return res.ok ? res.stdout : undefined; +} + +async function _readState( + repoPath: string, +): Promise<{ ahead: number; behind: number; dirty: boolean }> { + const counts = await _execGit( + ["rev-list", "--left-right", "--count", "HEAD...@{u}"], + repoPath, + 5000, + ); + let ahead = 0; + let behind = 0; + if (counts.ok) { + const [a, b] = counts.stdout.split(/\s+/); + ahead = parseInt(a ?? "0", 10) || 0; + behind = parseInt(b ?? "0", 10) || 0; + } + const dirtyRes = await _execGit( + ["status", "--porcelain"], + repoPath, + 5000, + ); + const dirty = dirtyRes.ok && dirtyRes.stdout.length > 0; + return { ahead, behind, dirty }; +} + +function _execGitSync( + args: string[], + cwd: string, +): { ok: boolean; stdout: string } { + try { + const proc = Bun.spawnSync(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = proc.stdout.toString().trim(); + return { ok: proc.exitCode === 0, stdout }; + } catch { + return { ok: false, stdout: "" }; + } +} + +function _readCurrentBranchSync(repoPath: string): string | undefined { + const res = _execGitSync( + ["rev-parse", "--abbrev-ref", "HEAD"], + repoPath, + ); + if (!res.ok) return undefined; + return res.stdout && res.stdout !== "HEAD" ? res.stdout : undefined; +} + +function _readUpstreamSync(repoPath: string): string | undefined { + const res = _execGitSync( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + repoPath, + ); + return res.ok && res.stdout ? res.stdout : undefined; +} + +function _readAheadSync(repoPath: string): number { + const res = _execGitSync( + ["rev-list", "--count", "@{u}..HEAD"], + repoPath, + ); + return res.ok ? parseInt(res.stdout, 10) || 0 : 0; +} + +function _readBehindSync(repoPath: string): number { + const res = _execGitSync( + ["rev-list", "--count", "HEAD..@{u}"], + repoPath, + ); + return res.ok ? parseInt(res.stdout, 10) || 0 : 0; +} + +function _readDirtySync(repoPath: string): boolean { + const res = _execGitSync(["status", "--porcelain"], repoPath); + return res.ok && res.stdout.length > 0; +} + +function _basename(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; +} + +function _compressErr(detail: string): string { + const first = detail.split("\n").find((l) => l.trim()) ?? ""; + return first.length > 160 ? first.slice(0, 160) + "…" : first; +} diff --git a/src/kernel/workspaces/index.ts b/src/kernel/workspaces/index.ts index f5990c2..93fef2e 100644 --- a/src/kernel/workspaces/index.ts +++ b/src/kernel/workspaces/index.ts @@ -1 +1,2 @@ +export * from "./git-sync"; export * from "./store"; diff --git a/src/kernel/workspaces/store.ts b/src/kernel/workspaces/store.ts index 7603a2c..0dd7536 100644 --- a/src/kernel/workspaces/store.ts +++ b/src/kernel/workspaces/store.ts @@ -392,9 +392,9 @@ export class GroupWorkspaceStore { ? "- _(none — run `/bind` in a chat to attach.)_" : bindings.map((b) => `- \`${b.chat_id}\``).join("\n"); const active = workspace.active_repo - ? `\`${workspace.active_repo}\`${ - workspace.active_branch ? ` @ \`${workspace.active_branch}\`` : "" - }` + ? `\`${workspace.active_repo}${ + workspace.active_branch ? " " + workspace.active_branch : "" + }\`` : "_(unset)_"; const lines = [ `# Workspace: ${workspace.name}`, diff --git a/src/shared/messaging/types/message.ts b/src/shared/messaging/types/message.ts index 33894ad..420846f 100644 --- a/src/shared/messaging/types/message.ts +++ b/src/shared/messaging/types/message.ts @@ -48,6 +48,12 @@ export const UserMessage = BaseMessage.extend({ channel_id: z.string().optional(), /** Feishu chat_id, when this message originated from a Feishu channel. */ chat_id: z.string().optional(), + /** + * Feishu chat type: "group" for group chats, "single" for 1:1 (P2P). + * Only meaningful when `chat_id` is set. Left undefined for non-Feishu + * sources so consumers fall back to "permissive" defaults. + */ + chat_type: z.enum(["group", "single"]).optional(), /** Feishu topic/thread id, when the message is inside a topic. */ thread_id: z.string().optional(), /** Provider-specific open_id of the sender (e.g. Feishu open_id). */ From 39dd013244904dc0b5a347b9c8c610896f875829 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 16:23:01 +0800 Subject: [PATCH 17/69] feat(setup): polish Feishu workspace cards Refine the /setup and /switch cards into denser workspace control panels with shared card helpers and compatibility-safe payloads. Remove low-signal explanatory copy, keep the current-state summaries focused, and add targeted tests for the card builders and markdown normalization. --- src/kernel/setup/card-ui.ts | 181 +++++++++++++++++++++++++++ src/kernel/setup/setup-card.ts | 204 +++++++++++++++++-------------- src/kernel/setup/switch-card.ts | 125 +++++++++---------- tests/kernel/setup/cards.test.ts | 147 ++++++++++++++++++++++ 4 files changed, 500 insertions(+), 157 deletions(-) create mode 100644 src/kernel/setup/card-ui.ts create mode 100644 tests/kernel/setup/cards.test.ts diff --git a/src/kernel/setup/card-ui.ts b/src/kernel/setup/card-ui.ts new file mode 100644 index 0000000..bd14fb7 --- /dev/null +++ b/src/kernel/setup/card-ui.ts @@ -0,0 +1,181 @@ +import type { + Card, + Color, + CollapsiblePanel, + Element, + MarkdownElement, +} from "../../community/feishu/messaging/types"; + +type CardTone = "info" | "success" | "warning" | "danger" | "neutral"; + +const CARD_TONE_STYLES: Record< + CardTone, + { + border: Color; + background: Color; + } +> = { + info: { + border: "wathet-200", + background: "wathet-50", + }, + success: { + border: "green-200", + background: "green-50", + }, + warning: { + border: "orange-200", + background: "orange-50", + }, + danger: { + border: "red-200", + background: "red-50", + }, + neutral: { + border: "grey-300", + background: "grey-50", + }, +}; + +export function buildCardIntro(options: { + title: string; + subtitle?: string; +}): MarkdownElement { + return buildMarkdown( + options.subtitle + ? [`**${options.title}**`, "", `${options.subtitle}`].join("\n") + : `**${options.title}**`, + ); +} + +export function buildMarkdown( + content: string, + extra: Partial> = {}, +): MarkdownElement { + return { + tag: "markdown", + content: optimizeCardMarkdown(content), + ...extra, + }; +} + +export function buildSectionPanel(options: { + title: string; + elements: Element[]; + expanded?: boolean; + tone?: CardTone; +}): CollapsiblePanel { + const tone = options.tone ?? "neutral"; + return { + tag: "collapsible_panel", + expanded: options.expanded ?? false, + background_color: CARD_TONE_STYLES[tone].background, + border: { + color: CARD_TONE_STYLES[tone].border, + corner_radius: "8px", + }, + padding: "0px", + vertical_spacing: "8px", + header: { + title: { + tag: "plain_text", + content: options.title, + text_size: "medium", + }, + icon: { + tag: "standard_icon", + token: "right_outlined", + color: "grey", + }, + icon_position: "right", + icon_expanded_angle: 90, + padding: "10px 12px 10px 12px", + width: "fill", + }, + elements: options.elements, + }; +} + +export function buildResultCard(options: { + title: string; + summary: string; + detail?: string[]; +}): Card { + const tone = inferToneFromSummary(options.summary); + const elements: Element[] = [ + buildCardIntro({ + title: options.title, + subtitle: summarizeForSubtitle(options.summary), + }), + buildMarkdown(options.summary), + ]; + + if ((options.detail?.length ?? 0) > 0) { + elements.push( + buildSectionPanel({ + title: `查看详情(${options.detail!.length} 项)`, + expanded: true, + tone, + elements: [buildMarkdown(options.detail!.join("\n"))], + }), + ); + } + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { + content: summarizeForSubtitle(options.summary), + }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +export function inferToneFromSummary(summary: string): CardTone { + if (summary.startsWith("✅")) return "success"; + if (summary.startsWith("⏳")) return "info"; + if (summary.startsWith("⚠️") || summary.startsWith("🚫")) return "warning"; + if (summary.startsWith("❌")) return "danger"; + return "neutral"; +} + +function summarizeForSubtitle(summary: string): string { + return summary.replace(/^[^\p{L}\p{N}`]+/u, "").slice(0, 80); +} + +/** + * Lightweight markdown normalization borrowed from the richer Feishu card + * pipeline in `happyclaw`, trimmed to what these setup cards need. + */ +export function optimizeCardMarkdown(text: string): string { + if (!text.trim()) return text; + const mark = "__CARD_CODE_BLOCK__"; + const codeBlocks: string[] = []; + + let normalized = text.replace(/```[\s\S]*?```/g, (block) => { + const idx = codeBlocks.push(block) - 1; + return `${mark}${idx}__`; + }); + + if (/^#{1,3} /m.test(normalized)) { + normalized = normalized.replace(/^#{2,6} (.+)$/gm, "##### $1"); + normalized = normalized.replace(/^# (.+)$/gm, "#### $1"); + } + + normalized = normalized.replace(/^([^|\n].*)\n(\|.+\|)/gm, "$1\n\n$2"); + normalized = normalized.replace(/\n{3,}/g, "\n\n"); + + codeBlocks.forEach((block, idx) => { + normalized = normalized.replace(`${mark}${idx}__`, `\n
\n${block}\n
\n`); + }); + + return normalized.trim(); +} diff --git a/src/kernel/setup/setup-card.ts b/src/kernel/setup/setup-card.ts index 4f1e685..1bb7020 100644 --- a/src/kernel/setup/setup-card.ts +++ b/src/kernel/setup/setup-card.ts @@ -8,10 +8,16 @@ import type { Element, FormElement, InputElement, - MarkdownElement, SelectStaticElement, } from "../../community/feishu/messaging/types"; +import { + buildCardIntro, + buildMarkdown, + buildResultCard, + buildSectionPanel, +} from "./card-ui"; + /** * Field naming convention used by both the card renderer and the submit * handler. Keep them in one place so the two sides cannot drift apart. @@ -54,38 +60,41 @@ export interface SetupCardOptions { /** * Build the interactive `/setup` card. * - * Layout: - * - Header: prompt text - * - Form body: one row per predefined repo (checker + branch input + description) - * - Primary-repo selector (always shown; default = first repo in catalog) - * - Submit button ("初始化") with `action_type: "form_submit"`. On submit the - * server receives `action.name = "setup_submit"` and `action.form_value` - * carries the checker + input + select values. - * - * Re-runs pass `options.prefills` so already-cloned repos render as - * locked-on checkers with their current branch pre-filled; new catalog - * entries render as unchecked and can be picked to be added. - * - * Card-to-pending correlation happens on the kernel side via `message_id`, - * so the card itself carries no setup_id. + * Compared with the original plain-markdown card, this version adds: + * - a proper card head so the action is recognizable in chat history + * - a collapsible "how it works" section + * - a current-state summary when editing an existing workspace + * - clearer per-repo rows (name/description/status separated from branch input) */ export function buildSetupCard( catalog: PredefinedRepo[], options: SetupCardOptions = {}, ): Card { - const formElements: Element[] = []; const prefills = options.prefills ?? {}; const hasExisting = Object.values(prefills).some((p) => p.already_cloned); + const lockedRepos = catalog + .filter((repo) => prefills[repo.name]?.already_cloned) + .map((repo) => repo.name); + + const formElements: Element[] = []; if (options.workspace_name) { formElements.push(..._buildWorkspaceNameInput(options.workspace_name)); } + + formElements.push( + buildMarkdown("**仓库与分支**"), + ); + for (const repo of catalog) { formElements.push(_buildRepoRow(repo, prefills[repo.name])); } + formElements.push( + buildMarkdown("**主仓库**"), + ); formElements.push(_buildPrimarySelect(catalog, options.primary_repo)); - formElements.push(_buildSubmitButton()); + formElements.push(_buildSubmitButton(hasExisting)); const form: FormElement = { tag: "form", @@ -93,23 +102,31 @@ export function buildSetupCard( elements: formElements, }; - const headerLines = hasExisting - ? [ - "**📦 更新当前群的 workspace**", - "", - "已绑定的仓库保持勾选(不可取消)。可以修改其分支,或勾选新仓库加入。", - "底部「主仓库」可切换后续消息默认使用的仓库。", - ] - : [ - "**📦 初始化当前群的 workspace**", - "", - "勾选要克隆的仓库;分支默认 `master`,留空即使用 master。", - "选多个仓库时,请在底部选一个作为「主仓库」(后续消息的默认仓库)。", - ]; - const header: MarkdownElement = { - tag: "markdown", - content: headerLines.join("\n"), - }; + const bodyElements: Element[] = [ + buildCardIntro({ + title: hasExisting ? "更新 Workspace" : "初始化 Workspace", + }), + ]; + + if (hasExisting) { + const currentSummaryLines = [ + `- 已纳管仓库:${lockedRepos.map((name) => `\`${name}\``).join("、") || "(暂无)"}`, + `- 当前主仓库:\`${options.primary_repo ?? "(未设置)"}\``, + ]; + if (options.workspace_name?.id) { + currentSummaryLines.push(`- Workspace ID:\`${options.workspace_name.id}\``); + } + bodyElements.push( + buildSectionPanel({ + title: "当前状态", + expanded: true, + tone: "neutral", + elements: [buildMarkdown(currentSummaryLines.join("\n"))], + }), + ); + } + + bodyElements.push(form); return { schema: "2.0", @@ -122,7 +139,9 @@ export function buildSetupCard( }, }, body: { - elements: [header, form], + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements: bodyElements, }, }; } @@ -130,13 +149,6 @@ export function buildSetupCard( function _buildWorkspaceNameInput( state: NonNullable, ): Element[] { - // Stack label + input vertically so narrow cards (mobile) don't squeeze - // the label into a sliver. The stable id sits on its own line as a small - // annotation below the input; on first run it's omitted entirely. - const label: MarkdownElement = { - tag: "markdown", - content: "**Workspace 名称**", - }; const input: InputElement = { tag: "input", name: SETUP_FIELD.workspaceName, @@ -144,13 +156,20 @@ function _buildWorkspaceNameInput( default_value: state.value, width: "fill", }; - const elements: Element[] = [label, input]; + + const elements: Element[] = [ + buildMarkdown("**Workspace 名称**"), + input, + ]; + if (state.id) { - elements.push({ - tag: "markdown", - content: `当前 ID \`${state.id}\``, - }); + elements.push( + buildMarkdown(`当前 Workspace ID:\`${state.id}\``, { + text_size: "notation", + }), + ); } + return elements; } @@ -158,27 +177,18 @@ function _buildRepoRow( repo: PredefinedRepo, prefill?: RepoPrefill, ): ColumnSetElement { - // NOTE: Feishu's checker.text ONLY accepts `plain_text`, not `markdown`. - // Attempting markdown yields "type of element is not supported tag: markdown" - // (error 200621). We inline the description into the label as a plain string. - const label = repo.description - ? `${repo.name} — ${repo.description}` - : repo.name; const alreadyCloned = prefill?.already_cloned === true; const checker: CheckerElement = { tag: "checker", name: SETUP_FIELD.repoChecker(repo.name), - text: { tag: "plain_text", content: label }, + text: { tag: "plain_text", content: repo.name }, checked: alreadyCloned, - // Already-cloned repos are locked on so the user can't accidentally drop - // an existing binding. New catalog entries stay fully editable. disabled: alreadyCloned, }; const branchInput: InputElement = { tag: "input", name: SETUP_FIELD.branchInput(repo.name), placeholder: { tag: "plain_text", content: "master" }, - // Pre-fill with the repo's current branch so editing this field = switch. default_value: prefill?.current_branch, width: "fill", }; @@ -186,10 +196,40 @@ function _buildRepoRow( return { tag: "column_set", flex_mode: "stretch", - horizontal_spacing: "8px", + horizontal_spacing: "12px", columns: [ - { tag: "column", width: "weighted", weight: 1, elements: [checker] }, - { tag: "column", width: "140px", elements: [branchInput] }, + { + tag: "column", + width: "weighted", + weight: 1, + elements: [ + checker, + ...(repo.description + ? [ + buildMarkdown( + `${repo.description}`, + { text_size: "notation" }, + ), + ] + : []), + ...(alreadyCloned + ? [ + buildMarkdown( + "已存在于当前 workspace,可直接修改分支。", + { text_size: "notation" }, + ), + ] + : []), + ], + }, + { + tag: "column", + width: "160px", + elements: [ + buildMarkdown("分支", { text_size: "notation" }), + branchInput, + ], + }, ], }; } @@ -215,22 +255,17 @@ function _buildPrimarySelect( }; } -function _buildSubmitButton(): ButtonElement { - // IMPORTANT: use `action_type: "form_submit"` alone — do NOT combine with - // `behaviors: [{ type: "callback", ... }]`. Feishu's validator requires at - // least one recognizable submit button inside a form container, and a - // `callback` behavior makes the button look like a plain callback button - // instead, producing "there is no submit button in the form container". - // - // The setup flow correlates the submit event by `message_id` (we keep - // pending state keyed by the card's message id), so the button does not - // need to carry setup_id itself. +function _buildSubmitButton(hasExisting: boolean): ButtonElement { return { tag: "button", name: "setup_submit", - text: { tag: "plain_text", content: "提交" }, + text: { + tag: "plain_text", + content: hasExisting ? "保存并更新" : "开始初始化", + }, type: "primary", action_type: "form_submit", + width: "fill", }; } @@ -242,26 +277,9 @@ export function buildSetupResultCard( summary: string, perRepoLines: string[], ): Card { - const elements: Element[] = [ - { - tag: "markdown", - content: summary, - }, - ]; - if (perRepoLines.length > 0) { - elements.push({ - tag: "markdown", - content: perRepoLines.join("\n"), - }); - } - return { - schema: "2.0", - config: { - streaming_mode: false, - update_multi: true, - width_mode: "fill", - summary: { content: summary.slice(0, 80) }, - }, - body: { elements }, - }; + return buildResultCard({ + title: "Workspace 处理结果", + summary, + detail: perRepoLines, + }); } diff --git a/src/kernel/setup/switch-card.ts b/src/kernel/setup/switch-card.ts index b481c84..6112a13 100644 --- a/src/kernel/setup/switch-card.ts +++ b/src/kernel/setup/switch-card.ts @@ -5,10 +5,16 @@ import type { Card, Element, FormElement, - MarkdownElement, SelectStaticElement, } from "../../community/feishu/messaging/types"; +import { + buildCardIntro, + buildMarkdown, + buildResultCard, + buildSectionPanel, +} from "./card-ui"; + /** * Shared field names used by both the card renderer and the submit handler. * Keep them in one place so the two sides cannot drift apart. @@ -44,49 +50,50 @@ export interface SwitchCardOptions { } /** - * Build the `/switch` card. - * - * Layout: - * - Header - * - Current-binding summary (markdown) — omitted when chat has no binding - * - Form body: - * - select_static with one option per workspace + a trailing "detach" option - * - Submit button ("切换") - * - * Pending correlation happens on the kernel side via `message_id`, so the - * card carries no id of its own. + * Build the `/switch` card with a richer structure than the old plain + * markdown version: card head, current-binding summary, and a clearer form + * section that explains the detach option. */ export function buildSwitchCard(options: SwitchCardOptions): Card { const { workspaces, current } = options; - const header: MarkdownElement = { - tag: "markdown", - content: [ - "**🔀 切换当前会话的 workspace**", - "", - "从下面的列表里挑一个已有 workspace;选「取消绑定」则回到默认 workspace。", - ].join("\n"), - }; - - const body: Element[] = [header]; + const body: Element[] = [ + buildCardIntro({ + title: "切换 Workspace", + }), + ]; if (current) { - body.push({ - tag: "markdown", - content: [ - "**当前绑定:**", - `- 名称:\`${current.workspace_name}\``, - `- ID:\`${current.workspace_id}\``, - `- 活跃仓库:\`${current.active_repo ?? "(未设置)"}\``, - `- 活跃分支:\`${current.active_branch ?? "(未设置)"}\``, - ].join("\n"), - }); + body.push( + buildSectionPanel({ + title: "当前绑定", + expanded: true, + tone: "neutral", + elements: [ + buildMarkdown( + [ + `- 名称:\`${current.workspace_name}\``, + `- ID:\`${current.workspace_id}\``, + `- 活跃仓库:\`${current.active_repo ?? "(未设置)"}\``, + `- 活跃分支:\`${current.active_branch ?? "(未设置)"}\``, + ].join("\n"), + ), + ], + }), + ); } else { - body.push({ - tag: "markdown", - content: - "_当前会话还没有绑定任何 workspace,正在使用默认 workspace。_", - }); + body.push( + buildSectionPanel({ + title: "当前绑定", + expanded: true, + tone: "neutral", + elements: [ + buildMarkdown( + "_当前会话还没有绑定任何 workspace,正在使用默认 workspace。_", + ), + ], + }), + ); } const select = _buildWorkspaceSelect(workspaces, current?.workspace_id); @@ -94,7 +101,11 @@ export function buildSwitchCard(options: SwitchCardOptions): Card { const form: FormElement = { tag: "form", name: "switch_form", - elements: [select, submit], + elements: [ + buildMarkdown("**目标 Workspace**"), + select, + submit, + ], }; body.push(form); @@ -106,7 +117,11 @@ export function buildSwitchCard(options: SwitchCardOptions): Card { width_mode: "fill", summary: { content: "🔀 切换 workspace" }, }, - body: { elements: body }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements: body, + }, }; } @@ -115,32 +130,17 @@ export function buildSwitchCard(options: SwitchCardOptions): Card { * original card in place via `updateRawCard`. */ export function buildSwitchResultCard(summary: string, detail: string[] = []): Card { - const elements: Element[] = [ - { tag: "markdown", content: summary }, - ]; - if (detail.length > 0) { - elements.push({ tag: "markdown", content: detail.join("\n") }); - } - return { - schema: "2.0", - config: { - streaming_mode: false, - update_multi: true, - width_mode: "fill", - summary: { content: summary.slice(0, 80) }, - }, - body: { elements }, - }; + return buildResultCard({ + title: "Workspace 切换结果", + summary, + detail, + }); } function _buildWorkspaceSelect( workspaces: Workspace[], preselected?: string, ): SelectStaticElement { - // Feishu's select_static has no "empty" state — when there are no - // workspaces we still render a single disabled-feeling placeholder option - // so the card is valid. Callers should prefer to skip the card entirely in - // that case, but we don't want the renderer to throw either way. const options = workspaces.map((ws) => ({ text: { tag: "plain_text" as const, @@ -172,15 +172,12 @@ function _buildWorkspaceSelect( } function _buildSubmitButton(): ButtonElement { - // Uses `action_type: "form_submit"` — Feishu's form container needs at - // least one submit-type button; mixing `behaviors: [{type:"callback"}]` - // here would cause the container to reject the button as non-submit - // ("there is no submit button in the form container"). return { tag: "button", name: "switch_submit", - text: { tag: "plain_text", content: "✅ 切换" }, + text: { tag: "plain_text", content: "确认切换" }, type: "primary", action_type: "form_submit", + width: "fill", }; } diff --git a/tests/kernel/setup/cards.test.ts b/tests/kernel/setup/cards.test.ts new file mode 100644 index 0000000..ca9640b --- /dev/null +++ b/tests/kernel/setup/cards.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; + +import { optimizeCardMarkdown } from "@/kernel/setup/card-ui"; +import { + buildSetupCard, + buildSetupResultCard, +} from "@/kernel/setup/setup-card"; +import { + buildSwitchCard, + buildSwitchResultCard, + SWITCH_DETACH_VALUE, +} from "@/kernel/setup/switch-card"; + +describe("buildSetupCard", () => { + test("renders richer structure for first-time setup", () => { + const card = buildSetupCard( + [ + { + name: "agentara", + description: "主仓库", + git_url: "git@example.com/agentara.git", + }, + { + name: "happyclaw", + description: "参考实现", + git_url: "git@example.com/happyclaw.git", + }, + ], + { + workspace_name: { value: "demo-space", locked: false }, + }, + ); + + expect(card.head).toBeUndefined(); + expect(card.body.elements[0]).toMatchObject({ + tag: "markdown", + }); + expect(card.body.elements[1]).toMatchObject({ + tag: "form", + }); + + const form = card.body.elements[1]; + expect(form).toBeTruthy(); + if (!form || form.tag !== "form") throw new Error("expected setup form"); + + const primaryLabel = form.elements.find( + (element) => + element.tag === "markdown" && element.content.includes("主仓库"), + ); + expect(primaryLabel).toBeTruthy(); + + const repoRow = form.elements.find((element) => element.tag === "column_set"); + expect(repoRow).toBeTruthy(); + }); + + test("shows current-state panel for existing workspace", () => { + const card = buildSetupCard( + [ + { + name: "agentara", + description: "主仓库", + git_url: "git@example.com/agentara.git", + }, + ], + { + prefills: { + agentara: { + already_cloned: true, + current_branch: "dev", + }, + }, + primary_repo: "agentara", + workspace_name: { value: "demo-space", locked: true, id: "ws_123" }, + }, + ); + + expect(card.head).toBeUndefined(); + expect(card.body.elements[1]).toMatchObject({ + tag: "collapsible_panel", + header: { title: { content: "当前状态" } }, + }); + expect(card.body.elements[2]).toMatchObject({ tag: "form" }); + }); +}); + +describe("buildSwitchCard", () => { + test("renders current binding summary and detach option", () => { + const card = buildSwitchCard({ + workspaces: [ + { + id: "ws_1", + name: "alpha", + path: "/tmp/alpha", + active_repo: "agentara", + active_branch: "dev", + created_at: Date.now(), + updated_at: Date.now(), + }, + ], + current: { + workspace_id: "ws_1", + workspace_name: "alpha", + workspace_path: "/tmp/alpha", + active_repo: "agentara", + active_branch: "dev", + }, + }); + + expect(card.head).toBeUndefined(); + expect(card.body.elements[1]).toMatchObject({ + tag: "collapsible_panel", + header: { title: { content: "当前绑定" } }, + }); + + const form = card.body.elements[2]; + expect(form).toBeTruthy(); + if (!form || form.tag !== "form") throw new Error("expected switch form"); + + const select = form.elements.find((element) => element.tag === "select_static"); + expect(select).toBeTruthy(); + if (select?.tag !== "select_static") throw new Error("expected select"); + expect(select.options.at(-1)?.value).toBe(SWITCH_DETACH_VALUE); + }); +}); + +describe("result cards", () => { + test("derives success styling from summary", () => { + const setupResult = buildSetupResultCard("✅ 已完成", ["- `agentara`"]); + const switchResult = buildSwitchResultCard("⚠️ 需要重新选择"); + + expect(setupResult.head).toBeUndefined(); + expect(switchResult.head).toBeUndefined(); + expect(setupResult.body.elements[0]).toMatchObject({ tag: "markdown" }); + }); +}); + +describe("optimizeCardMarkdown", () => { + test("demotes large headings and preserves code blocks", () => { + const optimized = optimizeCardMarkdown( + "# Title\n\n```ts\nconst value = 1;\n```\n\n| a | b |\n| - | - |\n| 1 | 2 |", + ); + + expect(optimized).toContain("#### Title"); + expect(optimized).toContain("```ts\nconst value = 1;\n```"); + expect(optimized).toContain("
"); + }); +}); From 6fdcf1f5a652192669dd0a5af20790b329244202 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 16:46:29 +0800 Subject: [PATCH 18/69] feat(cards): compact slash command replies Convert builtin slash command responses to compact Feishu cards with text fallback so workspace and command status replies stay dense without losing delivery resilience. Trim low-value workspace copy, paths, and collapsible sections from setup/switch cards and result cards to keep the interaction focused on current state and next actions. --- src/kernel/commands/cards.ts | 50 +++++++++++ src/kernel/commands/handlers.ts | 138 +++++++++++++++++-------------- src/kernel/commands/types.ts | 11 ++- src/kernel/kernel.ts | 120 ++++++++++++++++++--------- src/kernel/setup/card-ui.ts | 26 ++++-- src/kernel/setup/setup-card.ts | 14 ++-- src/kernel/setup/setup-flow.ts | 2 - src/kernel/setup/switch-card.ts | 44 ++++------ tests/kernel/setup/cards.test.ts | 13 +-- 9 files changed, 264 insertions(+), 154 deletions(-) create mode 100644 src/kernel/commands/cards.ts diff --git a/src/kernel/commands/cards.ts b/src/kernel/commands/cards.ts new file mode 100644 index 0000000..0daeaab --- /dev/null +++ b/src/kernel/commands/cards.ts @@ -0,0 +1,50 @@ +import type { Card, Element } from "../../community/feishu/messaging/types"; +import { + buildCardIntro, + buildMarkdown, + buildSectionBlock, +} from "../setup/card-ui"; + +type CommandCardSection = { + title: string; + lines: string[]; +}; + +export function buildCommandCard(options: { + title: string; + lines?: string[]; + sections?: CommandCardSection[]; + summary?: string; +}): Card { + const elements: Element[] = [ + buildCardIntro({ title: options.title }), + ]; + + if ((options.lines?.length ?? 0) > 0) { + elements.push(buildMarkdown(options.lines!.join("\n"))); + } + + for (const section of options.sections ?? []) { + elements.push(...buildSectionBlock({ + title: section.title, + lines: section.lines, + })); + } + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { + content: options.summary ?? options.title, + }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index c77af1f..20c082b 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -8,7 +8,12 @@ import { type RepoSyncResult, } from "@/kernel/workspaces"; -import type { CommandContext, CommandHandler } from "./types"; +import { buildCommandCard } from "./cards"; +import type { + CardCommandResult, + CommandContext, + CommandHandler, +} from "./types"; /** * Non-LLM commands that run inside `_handleInboundMessage` before the @@ -60,6 +65,26 @@ async function execGit( return { ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code }; } +function cardReply( + title: string, + lines: string[], + options?: { + sections?: Array<{ title: string; lines: string[]; expanded?: boolean }>; + summary?: string; + }, +): CardCommandResult { + return { + kind: "card", + card: buildCommandCard({ + title, + lines, + sections: options?.sections, + summary: options?.summary, + }), + fallback_text: [title, ...lines].join("\n"), + }; +} + const bindHandler: CommandHandler = { name: "bind", description: "/bind [workspace-id] — 绑定当前群到一个 workspace;传 id 时复用已有空间", @@ -91,13 +116,12 @@ const bindHandler: CommandHandler = { ? `- 活跃仓库:\`${binding.active_repo}${ binding.active_branch ? " " + binding.active_branch : "" }\`` - : "- 活跃仓库:(未设置,可用 `/setup` 配置)"; - return [ - `✅ 当前群已绑定到已有 workspace:\`${binding.workspace_name}\``, + : "- 活跃仓库:(未设置)"; + return cardReply("绑定 Workspace", [ + `✅ 当前群已绑定到 \`${binding.workspace_name}\``, `- Workspace ID:\`${binding.workspace_id}\``, - `- 路径:\`${binding.workspace_path}\``, activeLine, - ].join("\n"); + ]); } const existing = ctx.workspaceStore.getBinding(chatId); // Empty patch: just ensure the binding row + workspace dir exist; don't @@ -105,20 +129,16 @@ const bindHandler: CommandHandler = { const binding = ctx.workspaceStore.upsertBinding(chatId, {}); ctx.logger.info({ chat_id: chatId, binding }, "group binding ensured"); if (existing) { - return [ - `ℹ️ 当前群已绑定 workspace:\`${binding.workspace_name}\``, + return cardReply("绑定 Workspace", [ + `ℹ️ 当前群已绑定 \`${binding.workspace_name}\``, `- Workspace ID:\`${binding.workspace_id}\``, - `- 路径:\`${binding.workspace_path}\``, - "使用 `/setup` 克隆或切换仓库、分支。", - ].join("\n"); + ]); } - return [ - `✅ 已为当前群创建 workspace:\`${binding.workspace_name}\``, + return cardReply("绑定 Workspace", [ + `✅ 已创建 \`${binding.workspace_name}\``, `- Workspace ID:\`${binding.workspace_id}\``, - `- 路径:\`${binding.workspace_path}\``, - "其他群可通过 `/bind ` 直接复用这个空间。", - "使用 `/setup` 克隆仓库并选择主仓库和分支。", - ].join("\n"); + "- 下一步:`/setup`", + ]); }, }; @@ -134,8 +154,8 @@ const unbindHandler: CommandHandler = { return "❌ /unbind 仅在飞书群内可用。"; } const removed = ctx.workspaceStore.deleteBinding(chatId); - if (!removed) return "ℹ️ 当前群未绑定。"; - return "✅ 群绑定已清除。后续消息将使用默认 workspace。"; + if (!removed) return cardReply("解绑 Workspace", ["ℹ️ 当前群未绑定。"]); + return cardReply("解绑 Workspace", ["✅ 已清除群绑定。"]); }, }; @@ -145,10 +165,7 @@ const statusHandler: CommandHandler = { async execute(ctx) { const chatId = requireChatId(ctx); if (!chatId) { - return [ - "ℹ️ 当前不在飞书群上下文,正在使用默认 workspace。", - `默认路径:\`${ctx.workspaceStore.resolve(null).cwd}\``, - ].join("\n"); + return cardReply("Workspace 状态", ["ℹ️ 当前不在飞书会话上下文,使用默认 workspace。"]); } const resolution = ctx.workspaceStore.resolve(chatId); const isP2P = ctx.message.chat_type === "single"; @@ -159,46 +176,40 @@ const statusHandler: CommandHandler = { ? "ℹ️ 当前单聊 **未绑定** 任何 workspace。" : "ℹ️ 当前群 **未绑定**。", ); - lines.push(`默认 workspace:\`${resolution.cwd}\``); if (isP2P) { - lines.push("使用 `/switch` 挑一个已有 workspace 绑定到单聊。"); + lines.push("- 可用:`/switch`"); } else { - lines.push( - "使用 `/setup` 初始化,或 `/bind ` 复用已有空间,或 `/switch` 交互式挑选。", - ); + lines.push("- 可用:`/setup` `/bind ` `/switch`"); } - return lines.join("\n"); + return cardReply("Workspace 状态", lines); } lines.push("**当前群绑定:**"); lines.push(`- Workspace ID:\`${resolution.binding.workspace_id}\``); lines.push(`- Workspace 名称:\`${resolution.binding.workspace_name}\``); - lines.push(`- Workspace 路径:\`${resolution.binding.workspace_path}\``); const activeRepo = resolution.binding.active_repo; const activeBranch = resolution.binding.active_branch; const activeLabel = activeRepo ? `\`${activeRepo}${activeBranch ? " " + activeBranch : ""}\`` : "(未设置)"; lines.push(`- 活跃仓库:${activeLabel}`); - lines.push("- 复用到其他群:`/bind `"); const repoStates = listRepoSyncState(resolution.binding.workspace_path); if (repoStates.length > 0) { - lines.push("", "**Workspace 中已克隆的仓库:**"); + const repoLines: string[] = []; for (const s of repoStates) { const primary = s.name === activeRepo ? " ← 活跃" : ""; const ahead_behind = formatAheadBehind(s.ahead, s.behind); const dirty = s.dirty ? " •" : ""; const label = s.branch ? `${s.name} ${s.branch}` : s.name; const suffix = ahead_behind ? ` ${ahead_behind}` : ""; - lines.push(`- \`${label}\`${suffix}${dirty}${primary}`); + repoLines.push(`- \`${label}\`${suffix}${dirty}${primary}`); } - lines.push("", "_使用 `/sync` 拉取远端更新。_"); + return cardReply("Workspace 状态", lines, { + sections: [{ title: "仓库", lines: repoLines }], + }); } else { - lines.push("", "_Workspace 还没有克隆任何仓库。_"); - } - if (ctx.message.thread_id) { - lines.push("", `**当前话题的 session:** \`${ctx.message.session_id}\``); + lines.push("- 仓库:0"); } - return lines.join("\n"); + return cardReply("Workspace 状态", lines); }, }; @@ -215,11 +226,11 @@ const syncHandler: CommandHandler = { ctx.logger.info({ workspace_path: workspacePath }, "manual /sync requested"); const results = await syncWorkspace(workspacePath, { pull: true }); if (results.length === 0) { - return `_\`${workspacePath}\` 下没有 git 仓库。_`; + return cardReply("同步结果", ["ℹ️ 当前 workspace 下没有 git 仓库。"]); } - const lines = [`**\`${workspacePath}\` 同步结果:**`]; - for (const r of results) lines.push(_formatSyncLine(r)); - return lines.join("\n"); + return cardReply("同步结果", [], { + sections: [{ title: "仓库", lines: results.map(_formatSyncLine) }], + }); }, }; @@ -232,15 +243,17 @@ const lsHandler: CommandHandler = { const workspacePath = resolution.binding?.workspace_path ?? resolution.cwd; const repos = listRepoBasenames(workspacePath); if (repos.length === 0) { - return `_\`${workspacePath}\` 下还没有仓库。使用 \`/clone \` 添加一个。_`; + return cardReply("仓库", ["ℹ️ 当前 workspace 还没有仓库。"]); } const primary = resolution.binding?.active_repo; - const lines = [`**\`${workspacePath}\` 下的仓库:**`]; + const lines: string[] = []; for (const name of repos) { const mark = name === primary ? " ← 活跃" : ""; lines.push(`- \`${name}\`${mark}`); } - return lines.join("\n"); + return cardReply("仓库", [], { + sections: [{ title: "列表", lines }], + }); }, }; @@ -273,10 +286,7 @@ const cloneHandler: CommandHandler = { if (!result.ok) { return `❌ \`git clone\` 失败:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; } - return [ - `✅ 已克隆 \`${name}\` 到 workspace。`, - "继续执行 `/setup` 选择主仓库和分支。", - ].join("\n"); + return cardReply("克隆完成", [`✅ 已克隆 \`${name}\`.`]); }, }; @@ -295,7 +305,7 @@ const checkoutHandler: CommandHandler = { if (!branch) return "用法:`/checkout <分支>`"; const resolution = ctx.workspaceStore.resolve(chatId); if (!resolution.binding?.active_repo) { - return "❌ 当前群没有活跃仓库。请先执行 `/bind <仓库> <分支>`。"; + return "❌ 当前群没有活跃仓库。请先执行 `/setup`。"; } const repoPath = join( resolution.binding.workspace_path, @@ -311,7 +321,9 @@ const checkoutHandler: CommandHandler = { return `❌ \`git checkout ${branch}\` 失败:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; } ctx.workspaceStore.upsertBinding(chatId, { active_branch: branch }); - return `✅ \`${resolution.binding.active_repo}\` 已切换到分支 \`${branch}\`。`; + return cardReply("切换分支", [ + `✅ \`${resolution.binding.active_repo}\` 已切换到 \`${branch}\``, + ]); }, }; @@ -319,14 +331,20 @@ export const helpHandler: CommandHandler = { name: "help", description: "/help — 显示所有可用命令", async execute() { - return [ - "**可用命令(不经大模型直接执行):**", - ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), - "- /help — 显示本消息", - "- /stop — 取消当前 session 正在执行的任务", - "- /setup — 打开交互卡片,创建或更新 workspace,并设置主仓库/分支(仅群聊)", - "- /switch — 打开交互卡片,切换当前会话到已有 workspace(群聊 & 单聊)", - ].join("\n"); + return cardReply("可用命令", [], { + sections: [ + { + title: "命令", + lines: [ + ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), + "- /help — 显示本消息", + "- /stop — 取消当前 session 正在执行的任务", + "- /setup — 打开 workspace 配置卡片(仅群聊)", + "- /switch — 打开 workspace 切换卡片(群聊 & 单聊)", + ], + }, + ], + }); }, }; diff --git a/src/kernel/commands/types.ts b/src/kernel/commands/types.ts index 58eb9ba..dd2cb6d 100644 --- a/src/kernel/commands/types.ts +++ b/src/kernel/commands/types.ts @@ -1,5 +1,6 @@ import type { Logger, UserMessage } from "@/shared"; +import type { Card } from "../../community/feishu/messaging/types"; import type { GroupWorkspaceStore } from "../workspaces"; /** @@ -21,6 +22,14 @@ export interface CommandContext { logger: Logger; } +export interface CardCommandResult { + kind: "card"; + card: Card; + fallback_text: string; +} + +export type CommandResult = string | CardCommandResult; + /** A gateway-level command that bypasses the LLM entirely. */ export interface CommandHandler { /** Command name without the leading slash (lowercase). */ @@ -31,5 +40,5 @@ export interface CommandHandler { execute( // eslint-disable-next-line no-unused-vars ctx: CommandContext, - ): Promise; + ): Promise; } diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 16b10bd..3f6fa69 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -1,5 +1,6 @@ import { FeishuMessageChannel } from "@/community/feishu"; import * as feishuMessagingSchema from "@/community/feishu/messaging/data"; +import type { Card } from "@/community/feishu/messaging/types"; import { DataConnection } from "@/data"; import type { AssistantMessage, CardActionPayload, UserMessage } from "@/shared"; import { @@ -13,7 +14,8 @@ import { import { HonoServer } from "../server"; -import { CommandRegistry, parseCommand } from "./commands"; +import { CommandRegistry, parseCommand, type CardCommandResult } from "./commands"; +import { buildCommandCard } from "./commands/cards"; import { MultiChannelMessageGateway } from "./messaging"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; @@ -258,14 +260,21 @@ class Kernel { const handler = this._commandRegistry.get(parsed.name); if (!handler) return false; let replyText: string; + let replyCard: CardCommandResult | null = null; try { - replyText = await handler.execute({ + const result = await handler.execute({ message, args: parsed.args, raw: parsed.raw, workspaceStore: this._workspaceStore, logger: this._logger, }); + if (typeof result === "string") { + replyText = result; + } else { + replyText = result.fallback_text; + replyCard = result; + } } catch (err) { this._logger.error( { err, command: parsed.name, chat_id: message.chat_id }, @@ -273,18 +282,11 @@ class Kernel { ); replyText = `❌ 命令 \`/${parsed.name}\` 执行失败:${(err as Error).message}`; } - await this._messageGateway.replyMessage( - message.id, - { - role: "assistant", - session_id: message.session_id, - content: [{ type: "text", text: replyText }], - }, - { - channelId: message.channel_id, - streaming: false, - replyInThread: false, - }, + await this._replyTextOrCard( + message, + replyText, + replyCard?.card, + parsed.name, ); return true; }; @@ -296,36 +298,78 @@ class Kernel { if (runningTaskId) { await this._taskDispatcher.deleteTask(runningTaskId); - await this._messageGateway.replyMessage( - message.id, - { - role: "assistant", - session_id: sessionId, - content: [{ type: "text", text: "✅ 任务已取消。" }], - }, - { - channelId: message.channel_id, - streaming: false, - replyInThread: false, - }, + await this._replyTextOrCard( + message, + "✅ 任务已取消。", + buildCommandCard({ + title: "停止任务", + lines: ["✅ 任务已取消。"], + }), + "stop", ); } else { - await this._messageGateway.replyMessage( - message.id, - { - role: "assistant", - session_id: sessionId, - content: [{ type: "text", text: "ℹ️ 当前 session 没有正在执行的任务。" }], - }, - { - channelId: message.channel_id, - streaming: false, - replyInThread: false, - }, + await this._replyTextOrCard( + message, + "ℹ️ 当前 session 没有正在执行的任务。", + buildCommandCard({ + title: "停止任务", + lines: ["ℹ️ 当前 session 没有正在执行的任务。"], + }), + "stop", ); } }; + private async _replyTextOrCard( + message: UserMessage, + text: string, + card?: Card, + commandName?: string, + ): Promise { + if ( + card && + message.channel_id && + message.chat_id && + this._feishuChannels.get(message.channel_id) + ) { + try { + await this._feishuChannels.get(message.channel_id)!.sendRawCard( + message.chat_id, + card, + { + replyTo: message.id, + replyInThread: false, + }, + ); + return; + } catch (err) { + this._logger.error( + { + err, + command: commandName, + message_id: message.id, + chat_id: message.chat_id, + }, + "command card reply failed; falling back to text", + ); + } + } + + await this._messageGateway.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }, + { + channelId: message.channel_id, + streaming: false, + replyInThread: false, + }, + ); + } + private _handleMessageRecall = async ( messageId: string, channelId: string, diff --git a/src/kernel/setup/card-ui.ts b/src/kernel/setup/card-ui.ts index bd14fb7..75d1d40 100644 --- a/src/kernel/setup/card-ui.ts +++ b/src/kernel/setup/card-ui.ts @@ -96,6 +96,16 @@ export function buildSectionPanel(options: { }; } +export function buildSectionBlock(options: { + title: string; + lines: string[]; +}): Element[] { + return [ + buildMarkdown(`**${options.title}**`), + buildMarkdown(options.lines.join("\n")), + ]; +} + export function buildResultCard(options: { title: string; summary: string; @@ -111,14 +121,14 @@ export function buildResultCard(options: { ]; if ((options.detail?.length ?? 0) > 0) { - elements.push( - buildSectionPanel({ - title: `查看详情(${options.detail!.length} 项)`, - expanded: true, - tone, - elements: [buildMarkdown(options.detail!.join("\n"))], - }), - ); + if (tone !== "neutral") { + elements.push( + buildMarkdown(`详情`, { + text_size: "notation", + }), + ); + } + elements.push(buildMarkdown(options.detail!.join("\n"))); } return { diff --git a/src/kernel/setup/setup-card.ts b/src/kernel/setup/setup-card.ts index 1bb7020..c70a64d 100644 --- a/src/kernel/setup/setup-card.ts +++ b/src/kernel/setup/setup-card.ts @@ -15,7 +15,7 @@ import { buildCardIntro, buildMarkdown, buildResultCard, - buildSectionPanel, + buildSectionBlock, } from "./card-ui"; /** @@ -116,14 +116,10 @@ export function buildSetupCard( if (options.workspace_name?.id) { currentSummaryLines.push(`- Workspace ID:\`${options.workspace_name.id}\``); } - bodyElements.push( - buildSectionPanel({ - title: "当前状态", - expanded: true, - tone: "neutral", - elements: [buildMarkdown(currentSummaryLines.join("\n"))], - }), - ); + bodyElements.push(...buildSectionBlock({ + title: "当前状态", + lines: currentSummaryLines, + })); } bodyElements.push(form); diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index e28503f..bca25a1 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -281,8 +281,6 @@ export class SetupFlow { const lines = [ `- Workspace ID: \`${binding.workspace_id}\``, `- Workspace 名称: \`${binding.workspace_name}\``, - `- Workspace 路径: \`${binding.workspace_path}\``, - "- 其他群可用 `/bind ` 复用这个空间。", ...results.map(_formatResultLine), ]; const summary = activeRepo && activeBranch diff --git a/src/kernel/setup/switch-card.ts b/src/kernel/setup/switch-card.ts index 6112a13..84bcea6 100644 --- a/src/kernel/setup/switch-card.ts +++ b/src/kernel/setup/switch-card.ts @@ -12,7 +12,7 @@ import { buildCardIntro, buildMarkdown, buildResultCard, - buildSectionPanel, + buildSectionBlock, } from "./card-ui"; /** @@ -64,36 +64,20 @@ export function buildSwitchCard(options: SwitchCardOptions): Card { ]; if (current) { - body.push( - buildSectionPanel({ - title: "当前绑定", - expanded: true, - tone: "neutral", - elements: [ - buildMarkdown( - [ - `- 名称:\`${current.workspace_name}\``, - `- ID:\`${current.workspace_id}\``, - `- 活跃仓库:\`${current.active_repo ?? "(未设置)"}\``, - `- 活跃分支:\`${current.active_branch ?? "(未设置)"}\``, - ].join("\n"), - ), - ], - }), - ); + body.push(...buildSectionBlock({ + title: "当前绑定", + lines: [ + `- 名称:\`${current.workspace_name}\``, + `- ID:\`${current.workspace_id}\``, + `- 活跃仓库:\`${current.active_repo ?? "(未设置)"}\``, + `- 活跃分支:\`${current.active_branch ?? "(未设置)"}\``, + ], + })); } else { - body.push( - buildSectionPanel({ - title: "当前绑定", - expanded: true, - tone: "neutral", - elements: [ - buildMarkdown( - "_当前会话还没有绑定任何 workspace,正在使用默认 workspace。_", - ), - ], - }), - ); + body.push(...buildSectionBlock({ + title: "当前绑定", + lines: ["- 当前会话未绑定 workspace"], + })); } const select = _buildWorkspaceSelect(workspaces, current?.workspace_id); diff --git a/tests/kernel/setup/cards.test.ts b/tests/kernel/setup/cards.test.ts index ca9640b..c777647 100644 --- a/tests/kernel/setup/cards.test.ts +++ b/tests/kernel/setup/cards.test.ts @@ -76,10 +76,11 @@ describe("buildSetupCard", () => { expect(card.head).toBeUndefined(); expect(card.body.elements[1]).toMatchObject({ - tag: "collapsible_panel", - header: { title: { content: "当前状态" } }, + tag: "markdown", + content: "**当前状态**", }); - expect(card.body.elements[2]).toMatchObject({ tag: "form" }); + expect(card.body.elements[2]).toMatchObject({ tag: "markdown" }); + expect(card.body.elements[3]).toMatchObject({ tag: "form" }); }); }); @@ -108,11 +109,11 @@ describe("buildSwitchCard", () => { expect(card.head).toBeUndefined(); expect(card.body.elements[1]).toMatchObject({ - tag: "collapsible_panel", - header: { title: { content: "当前绑定" } }, + tag: "markdown", + content: "**当前绑定**", }); - const form = card.body.elements[2]; + const form = card.body.elements[3]; expect(form).toBeTruthy(); if (!form || form.tag !== "form") throw new Error("expected switch form"); From 4314d588e4341f6e9ed95121324ec1e44429deb1 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 17 Apr 2026 17:05:02 +0800 Subject: [PATCH 19/69] fix(kernel): use @ in repo branch card labels Format workspace card repo and branch labels as repo@branch instead of separating them with a space.\n\nThis keeps setup, status, sync and binding cards visually consistent and adds a small shared formatter with test coverage. --- src/kernel/commands/handlers.ts | 14 ++++++++------ src/kernel/repo-ref.ts | 10 ++++++++++ src/kernel/setup/setup-flow.ts | 9 +++++---- tests/kernel/setup/cards.test.ts | 8 ++++++++ 4 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 src/kernel/repo-ref.ts diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 20c082b..c3a7c1f 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; +import { formatRepoRef } from "@/kernel/repo-ref"; import { formatAheadBehind, listRepoSyncState, @@ -113,9 +114,10 @@ const bindHandler: CommandHandler = { "group rebound to existing workspace", ); const activeLine = binding.active_repo - ? `- 活跃仓库:\`${binding.active_repo}${ - binding.active_branch ? " " + binding.active_branch : "" - }\`` + ? `- 活跃仓库:\`${formatRepoRef( + binding.active_repo, + binding.active_branch, + )}\`` : "- 活跃仓库:(未设置)"; return cardReply("绑定 Workspace", [ `✅ 当前群已绑定到 \`${binding.workspace_name}\``, @@ -189,7 +191,7 @@ const statusHandler: CommandHandler = { const activeRepo = resolution.binding.active_repo; const activeBranch = resolution.binding.active_branch; const activeLabel = activeRepo - ? `\`${activeRepo}${activeBranch ? " " + activeBranch : ""}\`` + ? `\`${formatRepoRef(activeRepo, activeBranch)}\`` : "(未设置)"; lines.push(`- 活跃仓库:${activeLabel}`); const repoStates = listRepoSyncState(resolution.binding.workspace_path); @@ -199,7 +201,7 @@ const statusHandler: CommandHandler = { const primary = s.name === activeRepo ? " ← 活跃" : ""; const ahead_behind = formatAheadBehind(s.ahead, s.behind); const dirty = s.dirty ? " •" : ""; - const label = s.branch ? `${s.name} ${s.branch}` : s.name; + const label = formatRepoRef(s.name, s.branch); const suffix = ahead_behind ? ` ${ahead_behind}` : ""; repoLines.push(`- \`${label}\`${suffix}${dirty}${primary}`); } @@ -359,7 +361,7 @@ export const BUILTIN_COMMANDS: CommandHandler[] = [ ]; function _formatSyncLine(r: RepoSyncResult): string { - const label = r.branch ? `\`${r.name} ${r.branch}\`` : `\`${r.name}\``; + const label = `\`${formatRepoRef(r.name, r.branch)}\``; const ab = formatAheadBehind(r.ahead, r.behind); const abSuffix = ab ? ` ${ab}` : ""; switch (r.status) { diff --git a/src/kernel/repo-ref.ts b/src/kernel/repo-ref.ts new file mode 100644 index 0000000..90b76d1 --- /dev/null +++ b/src/kernel/repo-ref.ts @@ -0,0 +1,10 @@ +/** + * User-facing repo/branch label used across workspace cards and command + * results. Keep the separator centralized so copy stays consistent. + */ +export function formatRepoRef( + repo: string, + branch?: string | null, +): string { + return branch ? `${repo}@${branch}` : repo; +} diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index bca25a1..1b82a6f 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { formatRepoRef } from "@/kernel/repo-ref"; import type { Logger } from "@/shared"; import { createLogger, @@ -235,7 +236,7 @@ export class SetupFlow { buildSetupResultCard( `⏳ 正在初始化 \`${selections.map((s) => s.name).join("、")}\`…`, selections.map( - (s) => `- \`${s.name} ${s.branch}\``, + (s) => `- \`${formatRepoRef(s.name, s.branch)}\``, ), ), "pending-state", @@ -284,7 +285,7 @@ export class SetupFlow { ...results.map(_formatResultLine), ]; const summary = activeRepo && activeBranch - ? `✅ 初始化完成,主仓库 \`${activeRepo} ${activeBranch}\`。` + ? `✅ 初始化完成,主仓库 \`${formatRepoRef(activeRepo, activeBranch)}\`。` : "⚠️ workspace 已创建,但这次没有成功设置主仓库。"; await this._tryUpdateCard( channel, @@ -529,9 +530,9 @@ function _isTruthyChecker(v: unknown): boolean { function _formatResultLine(r: RepoResult): string { switch (r.status) { case "cloned": - return `- ✅ \`${r.name} ${r.branch}\` 已克隆`; + return `- ✅ \`${formatRepoRef(r.name, r.branch)}\` 已克隆`; case "exists": - return `- ℹ️ \`${r.name} ${r.branch}\` 已存在`; + return `- ℹ️ \`${formatRepoRef(r.name, r.branch)}\` 已存在`; case "checkout_failed": return ( `- ⚠️ \`${r.name}\` 已克隆,分支 \`${r.branch}\` 不可切换,` + diff --git a/tests/kernel/setup/cards.test.ts b/tests/kernel/setup/cards.test.ts index c777647..5e8f229 100644 --- a/tests/kernel/setup/cards.test.ts +++ b/tests/kernel/setup/cards.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { formatRepoRef } from "@/kernel/repo-ref"; import { optimizeCardMarkdown } from "@/kernel/setup/card-ui"; import { buildSetupCard, @@ -135,6 +136,13 @@ describe("result cards", () => { }); }); +describe("formatRepoRef", () => { + test("joins repo and branch with @", () => { + expect(formatRepoRef("agentara", "dev")).toBe("agentara@dev"); + expect(formatRepoRef("agentara", null)).toBe("agentara"); + }); +}); + describe("optimizeCardMarkdown", () => { test("demotes large headings and preserves code blocks", () => { const optimized = optimizeCardMarkdown( From e171497cd92031813d18e1f0a78edd6bdcfa86d3 Mon Sep 17 00:00:00 2001 From: xluos Date: Mon, 20 Apr 2026 17:25:47 +0800 Subject: [PATCH 20/69] feat(feishu): bypass @-mention for messages in bot-engaged threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up messages inside a thread the bot has already participated in are implicitly directed at the bot, so requiring users to @-mention on every reply is noise. The `feishu_threads` table already tracks every thread the bot has touched (either by starting one via reply/post or by being mentioned into an existing one), so reuse it as the bypass signal. The whitelist still applies — only allow-listed senders benefit. --- .../feishu/messaging/message-channel.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 1e09064..d85812f 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -829,8 +829,16 @@ export class FeishuMessageChannel messageType, receivedMessage.content, ); + // Messages inside a thread the bot has already engaged in are implicitly + // directed at the bot — no need to @-mention again. The `feishu_threads` + // table tracks every thread the bot has participated in (either by + // starting it via reply/post, or by being @-mentioned into it earlier). + const isInBotThread = this._isInBotThread(threadId); const mentionEnforced = - this._requireMention && chatType === "group" && !isSlashCommand; + this._requireMention && + chatType === "group" && + !isSlashCommand && + !isInBotThread; const isBotMentioned = !!this._botOpenId && !!mentions?.some((m) => m.id?.open_id === this._botOpenId); @@ -845,6 +853,7 @@ export class FeishuMessageChannel sender_open_id: senderOpenId, bot_mentioned: isBotMentioned, slash_command: isSlashCommand, + in_bot_thread: isInBotThread, passed: isAllowedSender && mentionOk, }, "inbound message", @@ -974,6 +983,28 @@ export class FeishuMessageChannel private _threadIdToSessionId = new Map(); + /** + * Returns true if `threadId` belongs to a thread the bot has engaged in + * before (either by starting it via reply/post, or by being @-mentioned + * into it). Used to bypass the @-mention requirement for follow-up + * messages inside a bot-owned topic. Cheap: in-memory cache first, then + * indexed single-row lookup on `feishu_threads`. + */ + private _isInBotThread(threadId: string | undefined): boolean { + if (!threadId) return false; + if (this._threadIdToSessionId.has(threadId)) return true; + const row = this._db + .select({ session_id: feishuThreads.session_id }) + .from(feishuThreads) + .where(eq(feishuThreads.thread_id, threadId)) + .get(); + if (row) { + this._threadIdToSessionId.set(threadId, row.session_id); + return true; + } + return false; + } + /** Persist a thread→session mapping to DB and update the in-memory cache. */ private _mapThreadToSession(threadId: string, sessionId: string) { this._threadIdToSessionId.set(threadId, sessionId); From c3a08ef7a069a3ce450b15d84d5cb091decddbf7 Mon Sep 17 00:00:00 2001 From: xluos Date: Mon, 20 Apr 2026 17:26:21 +0800 Subject: [PATCH 21/69] fix(workspace): trust HEAD; drop implicit pre-dispatch checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the kernel force-`checkout`ed the stored `active_branch` on every inbound dispatch, so a manual `git checkout` in the workspace would silently get reverted the next time a message arrived. On top of that, `/status` read the stored hint for the active-repo line while the per-repo list read HEAD — a single card could contradict itself. Unify on HEAD everywhere a user can see the "current branch": - remove the pre-dispatch checkout in the inbound-message handler - `/status`, `/bind`, and `/switch` cards read HEAD via a new `readRepoHead` helper exported from `git-sync` - `DEV_ASSETS_PRIMARY_BRANCH` env reflects HEAD, so the agent sees what it will actually run on The `active_branch` DB column stays (still written by `/setup` and `/checkout` as a last-known hint), but it no longer drives behavior or display. `/checkout ` remains the explicit branch-switch entry. --- src/kernel/commands/handlers.ts | 14 ++++++++++---- src/kernel/kernel.ts | 18 ------------------ src/kernel/setup/switch-flow.ts | 17 ++++++++++++++--- src/kernel/workspaces/git-sync.ts | 10 ++++++++++ src/kernel/workspaces/store.ts | 9 +++++++-- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index c3a7c1f..3a67d2c 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -5,6 +5,7 @@ import { formatRepoRef } from "@/kernel/repo-ref"; import { formatAheadBehind, listRepoSyncState, + readRepoHead, syncWorkspace, type RepoSyncResult, } from "@/kernel/workspaces"; @@ -116,7 +117,7 @@ const bindHandler: CommandHandler = { const activeLine = binding.active_repo ? `- 活跃仓库:\`${formatRepoRef( binding.active_repo, - binding.active_branch, + readRepoHead(join(binding.workspace_path, binding.active_repo)), )}\`` : "- 活跃仓库:(未设置)"; return cardReply("绑定 Workspace", [ @@ -189,12 +190,17 @@ const statusHandler: CommandHandler = { lines.push(`- Workspace ID:\`${resolution.binding.workspace_id}\``); lines.push(`- Workspace 名称:\`${resolution.binding.workspace_name}\``); const activeRepo = resolution.binding.active_repo; - const activeBranch = resolution.binding.active_branch; + const repoStates = listRepoSyncState(resolution.binding.workspace_path); + // Always read branch from on-disk HEAD, not the stored `active_branch` + // hint — the runner never force-checks-out, so "active" means "whatever + // the repo is currently on". + const activeState = activeRepo + ? repoStates.find((s) => s.name === activeRepo) + : undefined; const activeLabel = activeRepo - ? `\`${formatRepoRef(activeRepo, activeBranch)}\`` + ? `\`${formatRepoRef(activeRepo, activeState?.branch)}\`` : "(未设置)"; lines.push(`- 活跃仓库:${activeLabel}`); - const repoStates = listRepoSyncState(resolution.binding.workspace_path); if (repoStates.length > 0) { const repoLines: string[] = []; for (const s of repoStates) { diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 3f6fa69..8371359 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -412,24 +412,6 @@ class Kernel { ) => { const inboundMessage = payload.message; const resolution = this._workspaceStore.resolve(inboundMessage.chat_id); - if (resolution.binding?.active_repo && resolution.binding.active_branch) { - // Idempotent pre-checkout so the active repo is on the bound branch - // before the runner spawns. Runs synchronously with the dispatch — if - // it fails, we still proceed (binding may need user repair). - try { - const repoPath = `${resolution.binding.workspace_path}/${resolution.binding.active_repo}`; - const proc = Bun.spawn( - ["git", "-C", repoPath, "checkout", resolution.binding.active_branch], - { stdout: "pipe", stderr: "pipe" }, - ); - await proc.exited; - } catch (err) { - this._logger.warn( - { err, chat_id: inboundMessage.chat_id, binding: resolution.binding }, - "pre-dispatch git checkout failed; continuing", - ); - } - } const session = await this._sessionManager.resolveSession(sessionId, { channelId: inboundMessage.channel_id, chatId: inboundMessage.chat_id, diff --git a/src/kernel/setup/switch-flow.ts b/src/kernel/setup/switch-flow.ts index 283c299..591c795 100644 --- a/src/kernel/setup/switch-flow.ts +++ b/src/kernel/setup/switch-flow.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; + import type { Logger } from "@/shared"; import { createLogger, @@ -6,7 +8,7 @@ import { } from "@/shared"; import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; -import type { GroupWorkspaceStore } from "../workspaces"; +import { readRepoHead, type GroupWorkspaceStore } from "../workspaces"; import { buildSwitchCard, @@ -101,7 +103,10 @@ export class SwitchFlow { workspace_name: current.workspace_name, workspace_path: current.workspace_path, active_repo: current.active_repo, - active_branch: current.active_branch, + active_branch: current.active_repo + ? readRepoHead(join(current.workspace_path, current.active_repo)) ?? + null + : null, } : undefined, }); @@ -218,10 +223,16 @@ export class SwitchFlow { } const summary = `✅ 已切换到 workspace \`${binding.workspace_name}\`。`; + // Show the on-disk HEAD of the target workspace's active repo, not the + // stored `active_branch` — switching only rebinds; the repo keeps its + // current checkout. + const headBranch = binding.active_repo + ? readRepoHead(join(binding.workspace_path, binding.active_repo)) + : undefined; const detail = [ `- Workspace ID:\`${binding.workspace_id}\``, `- 活跃仓库:\`${binding.active_repo ?? "(未设置)"}\``, - `- 活跃分支:\`${binding.active_branch ?? "(未设置)"}\``, + `- 活跃分支:\`${headBranch ?? "(未设置)"}\``, ]; await this._tryUpdateCard( channel, diff --git a/src/kernel/workspaces/git-sync.ts b/src/kernel/workspaces/git-sync.ts index cef8ed4..680dbcd 100644 --- a/src/kernel/workspaces/git-sync.ts +++ b/src/kernel/workspaces/git-sync.ts @@ -240,6 +240,16 @@ export function listRepoSyncState(workspacePath: string): RepoSyncState[] { }); } +/** + * Read the current branch of a single repo (HEAD). Returns `undefined` when + * the repo is in a detached-HEAD state or git exits non-zero. Thin wrapper + * around the internal sync helper so other layers don't have to shell out + * themselves. + */ +export function readRepoHead(repoPath: string): string | undefined { + return _readCurrentBranchSync(repoPath); +} + /** * Render `↑a ↓b` for ahead/behind counts, omitting zero sides. Returns an * empty string when both sides are zero. Callers usually concatenate this diff --git a/src/kernel/workspaces/store.ts b/src/kernel/workspaces/store.ts index 0dd7536..df4d214 100644 --- a/src/kernel/workspaces/store.ts +++ b/src/kernel/workspaces/store.ts @@ -8,6 +8,8 @@ import type { DrizzleDB } from "@/data"; import { groupWorkspaces, workspaces } from "@/kernel/sessioning/data"; import { config, createLogger, uuid, type GroupWorkspace, type Workspace } from "@/shared"; +import { readRepoHead } from "./git-sync"; + const META_FILE_NAME = "AGENTARA.md"; /** @@ -227,8 +229,11 @@ export class GroupWorkspaceStore { const envExtras: Record = {}; if (binding.active_repo) { envExtras.DEV_ASSETS_PRIMARY_REPO = binding.active_repo; - if (binding.active_branch) { - envExtras.DEV_ASSETS_PRIMARY_BRANCH = binding.active_branch; + // Informational env — reflect the repo's actual HEAD, not the stored + // `active_branch` hint, so the agent sees what it will actually run on. + const head = readRepoHead(join(binding.workspace_path, binding.active_repo)); + if (head) { + envExtras.DEV_ASSETS_PRIMARY_BRANCH = head; } } return { From 11a6ec9bf42f62c6a6506265662c27fdf14ca7bf Mon Sep 17 00:00:00 2001 From: xluos Date: Mon, 20 Apr 2026 22:39:49 +0800 Subject: [PATCH 22/69] feat(commands): add /group, /ungroup, /allow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new commands for self-service group / whitelist management: - `/group @user1 @user2 ...` (P2P only): bot creates a new chat with the sender + mentioned users, transfers ownership to the sender, persists the chat to `feishu_bot_groups`, and posts a /setup card so the group is immediately ready for workspace init. - `/ungroup [name|chat_id]`: in-group with no args dismisses the current chat if the bot created it and the sender is the original `/group` caller; in P2P requires a name/chat_id and only matches groups the sender themselves created. - `/allow @user1 @user2 ...`: adds mentions to the channel's whitelist both in memory and by rewriting config.yaml, so admins don't have to edit the file by hand. Supporting changes: - UserMessage now carries `mentions`, populated from the Feishu event's mentions array — the three commands resolve @-placeholders to open_ids via this field. - `CommandContext` gains `feishuChannels` so regular command handlers can reach into channel-specific APIs without being kernel-special-cased. - New `feishu_bot_groups` table tracks which chats the bot created so `/ungroup` can authorize dismissal and look up by name in P2P. - `FeishuMessageChannel` gains `createChat`, `transferChatOwner`, `dismissChat`, `sendPlainText`, `addToWhitelist`, plus bot-group lookup/delete helpers. - `/group` is kernel-special-cased (like /setup, /switch) because it orchestrates across channel ops, DB writes, and SetupFlow. Requires Feishu app scopes: `im:chat:create`, `im:chat.owner:update`, `im:chat:delete` (operator). --- drizzle/0013_steady_korvac.sql | 7 + drizzle/meta/0013_snapshot.json | 407 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/community/feishu/messaging/data/schema.ts | 20 + .../feishu/messaging/message-channel.ts | 221 +++++++++- src/kernel/commands/handlers.ts | 116 +++++ src/kernel/commands/types.ts | 7 + src/kernel/group/group-flow.ts | 256 +++++++++++ src/kernel/kernel.ts | 20 + src/shared/messaging/types/message.ts | 21 + 10 files changed, 1081 insertions(+), 1 deletion(-) create mode 100644 drizzle/0013_steady_korvac.sql create mode 100644 drizzle/meta/0013_snapshot.json create mode 100644 src/kernel/group/group-flow.ts diff --git a/drizzle/0013_steady_korvac.sql b/drizzle/0013_steady_korvac.sql new file mode 100644 index 0000000..4b7ecca --- /dev/null +++ b/drizzle/0013_steady_korvac.sql @@ -0,0 +1,7 @@ +CREATE TABLE `feishu_bot_groups` ( + `chat_id` text PRIMARY KEY NOT NULL, + `channel_id` text NOT NULL, + `chat_name` text NOT NULL, + `creator_open_id` text NOT NULL, + `created_at` integer NOT NULL +); diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..38e9f38 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,407 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "da37cbce-6bd1-4f48-8180-8f958e06cd7c", + "prevId": "8d12163c-11da-4e53-b925-55107b8e5832", + "tables": { + "group_workspaces": { + "name": "group_workspaces", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_repo": { + "name": "active_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_branch": { + "name": "active_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_path_unique": { + "name": "workspaces_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_bot_groups": { + "name": "feishu_bot_groups", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_open_id": { + "name": "creator_open_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 40409cb..f19f61e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1776408029760, "tag": "0012_workspace_active_state", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1776695439707, + "tag": "0013_steady_korvac", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/community/feishu/messaging/data/schema.ts b/src/community/feishu/messaging/data/schema.ts index 5fb79ff..93cce48 100644 --- a/src/community/feishu/messaging/data/schema.ts +++ b/src/community/feishu/messaging/data/schema.ts @@ -16,3 +16,23 @@ export const feishuThreads = sqliteTable("feishu_threads", { /** Epoch milliseconds when the mapping was created. */ created_at: integer("created_at").notNull(), }); + +/** + * Groups the bot itself created via the `/group` command. + * + * Used by `/ungroup` to (a) authorize dismissal — only the original + * creator can tear down a group the bot made, and (b) look up groups by + * name when the command runs in P2P without a current-chat context. + */ +export const feishuBotGroups = sqliteTable("feishu_bot_groups", { + /** Feishu chat_id of the group created by the bot. */ + chat_id: text("chat_id").primaryKey(), + /** Channel id that created the group (for multi-channel deployments). */ + channel_id: text("channel_id").notNull(), + /** Display name given to the group at creation time. */ + chat_name: text("chat_name").notNull(), + /** open_id of the user who ran `/group` — authoritative for dismissal. */ + creator_open_id: text("creator_open_id").notNull(), + /** Epoch milliseconds when the group was created. */ + created_at: integer("created_at").notNull(), +}); diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index d85812f..fde9dcb 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -19,7 +19,7 @@ import { } from "@/shared"; -import { feishuThreads } from "./data"; +import { feishuBotGroups, feishuThreads } from "./data"; import { renderMessageCard, splitMarkdownByTables } from "./message-renderer"; import type { Card, MessageReceiveEventData } from "./types"; import { convertPostToMarkdown } from "./utils"; @@ -62,6 +62,15 @@ export class FeishuMessageChannel private _logger: Logger; private _requireMention: boolean; private _botOpenId?: string; + + /** + * Bot's own open_id as resolved at `start()`. `undefined` until the channel + * has started, and when `require_mention` is disabled the bot-info fetch + * is skipped so this stays undefined even post-start. + */ + get botOpenId(): string | undefined { + return this._botOpenId; + } private _allowedUserOpenIds?: Set; private _allowedUserEmails?: string[]; @@ -245,6 +254,197 @@ export class FeishuMessageChannel }); } + /** + * Post a plain-text message into an arbitrary chat. Returns the posted + * message's id so callers can anchor follow-up replies (e.g. `/group` + * sends a welcome line then anchors its `/setup` card as a reply to it). + * + * Distinct from `postMessage(AssistantMessage)` which renders a card to + * this channel's default `config.chatId`. + */ + async sendPlainText(chatId: string, text: string): Promise { + const { data } = await this._client.im.message.create({ + params: { receive_id_type: "chat_id" }, + data: { + receive_id: chatId, + msg_type: "text", + content: JSON.stringify({ text }), + }, + }); + if (!data?.message_id) { + throw new Error("Failed to post plain text message"); + } + return data.message_id; + } + + /** + * Create a new group chat. The bot itself ends up as the initial owner; + * callers that want a human owner should follow up with + * {@link transferChatOwner}. `memberOpenIds` are added at creation time so + * no separate add-members round-trip is needed. + * + * Requires the `im:chat:create` scope. + */ + async createChat(options: { + name: string; + memberOpenIds: string[]; + description?: string; + }): Promise { + const { data } = await this._client.im.chat.create({ + params: { user_id_type: "open_id" }, + data: { + name: options.name, + description: options.description, + chat_type: "private", + user_id_list: options.memberOpenIds, + }, + }); + if (!data?.chat_id) { + throw new Error("Feishu returned no chat_id when creating the chat"); + } + return data.chat_id; + } + + /** + * Transfer a group's owner to the specified user. The bot must currently + * be the owner. Requires the `im:chat.owner:update` scope. + */ + async transferChatOwner( + chatId: string, + ownerOpenId: string, + ): Promise { + await this._client.im.chat.update({ + path: { chat_id: chatId }, + params: { user_id_type: "open_id" }, + data: { owner_id: ownerOpenId }, + }); + } + + /** + * Dissolve a chat. Requires the bot to be the owner. Used by `/ungroup` + * to tear down groups the bot created earlier. + */ + async dismissChat(chatId: string): Promise { + await this._client.im.chat.delete({ + path: { chat_id: chatId }, + }); + } + + /** + * Look up a bot-created group by its chat_id. Returns undefined when the + * chat was not created by this bot (e.g. an existing group the bot was + * just added to). + */ + findBotGroup(chatId: string): + | { chat_id: string; chat_name: string; creator_open_id: string } + | undefined { + const row = this._db + .select({ + chat_id: feishuBotGroups.chat_id, + chat_name: feishuBotGroups.chat_name, + creator_open_id: feishuBotGroups.creator_open_id, + }) + .from(feishuBotGroups) + .where(eq(feishuBotGroups.chat_id, chatId)) + .get(); + return row ?? undefined; + } + + /** + * Find a bot-created group by either its display name or chat_id, scoped + * to a single creator. Used by `/ungroup ` from P2P so users can + * only dismiss groups they themselves created. Name match is exact + * (multiple groups may share a name; the caller must disambiguate). + */ + findBotGroupForCreator( + query: string, + creatorOpenId: string, + ): Array<{ chat_id: string; chat_name: string }> { + return this._db + .select({ + chat_id: feishuBotGroups.chat_id, + chat_name: feishuBotGroups.chat_name, + }) + .from(feishuBotGroups) + .where(eq(feishuBotGroups.creator_open_id, creatorOpenId)) + .all() + .filter((row) => row.chat_id === query || row.chat_name === query); + } + + /** Remove a bot-group record. Call after a successful `dismissChat`. */ + deleteBotGroupRecord(chatId: string): void { + this._db + .delete(feishuBotGroups) + .where(eq(feishuBotGroups.chat_id, chatId)) + .run(); + } + + /** + * Add one or more users to the runtime whitelist and persist the change to + * `config.yaml`. Returns the open_ids that were actually new (not already + * in the set) so callers can report a precise count back to the user. + * + * Mutates the channel's in-memory set immediately — new entries take effect + * on the very next inbound message without a restart. Persistence keeps + * the change across restarts; we re-use Bun's YAML parser/stringifier so + * the file stays round-trippable. + */ + async addToWhitelist(openIds: string[]): Promise { + if (!this._allowedUserOpenIds) { + // The whitelist was disabled (empty set accepts everyone). Initialize + // a fresh one — the newly-added users become the whole allow-list. + this._allowedUserOpenIds = new Set(); + } + const added: string[] = []; + for (const openId of openIds) { + if (!this._allowedUserOpenIds.has(openId)) { + this._allowedUserOpenIds.add(openId); + added.push(openId); + } + } + if (added.length === 0) return added; + try { + await this._persistWhitelistToConfig(); + } catch (err) { + this._logger.error( + { err, channel_id: this.id, added }, + "failed to persist whitelist addition; in-memory set was still updated", + ); + throw err; + } + return added; + } + + private async _persistWhitelistToConfig(): Promise { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const { config: cfgModule } = await import("@/shared"); + const configPath = path.join(cfgModule.paths.home, "config.yaml"); + const raw = await fs.readFile(configPath, "utf-8"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun.YAML types are not in @types yet + const parsed = (Bun as any).YAML.parse(raw) as { + messaging?: { + channels?: Array<{ + id: string; + params?: Record; + }>; + }; + }; + const channel = parsed.messaging?.channels?.find((c) => c.id === this.id); + if (!channel) { + throw new Error( + `channel \`${this.id}\` not found in config.yaml — whitelist write aborted`, + ); + } + if (!channel.params) channel.params = {}; + channel.params.allowed_user_ids = Array.from( + this._allowedUserOpenIds ?? [], + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun.YAML types are not in @types yet + const serialized = (Bun as any).YAML.stringify(parsed); + await fs.writeFile(configPath, serialized, "utf-8"); + } + /** * Exchange app credentials for a tenant access token. Used for REST calls * that the node-sdk doesn't expose directly (bot info, email→id lookup). @@ -885,6 +1085,23 @@ export class FeishuMessageChannel : chatType === "p2p" || chatType === "single" ? "single" : undefined; + // Propagate the raw Feishu mentions into a provider-agnostic list so + // downstream command handlers (`/group`, `/allow`) can resolve the + // `@_user_N` placeholders that appear in the text content. + const normalizedMentions: Array<{ + key: string; + open_id: string; + name?: string; + }> = []; + for (const m of mentions ?? []) { + const openId = m.id?.open_id; + if (!openId || !m.key) continue; + normalizedMentions.push({ + key: m.key, + open_id: openId, + name: m.name, + }); + } const userMessage: UserMessage = { id: messageId, session_id, @@ -894,6 +1111,8 @@ export class FeishuMessageChannel chat_type: normalizedChatType, thread_id: threadId, sender_open_id: senderOpenId, + mentions: + normalizedMentions.length > 0 ? normalizedMentions : undefined, content: [ await this._parseMessageContent( messageId, diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 3a67d2c..538f395 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -335,6 +335,119 @@ const checkoutHandler: CommandHandler = { }, }; +const ungroupHandler: CommandHandler = { + name: "ungroup", + description: + "/ungroup [群名或 chat_id] — 解散机器人创建的群(群内无参=当前群;单聊须指定)", + async execute(ctx) { + if (!ctx.message.channel_id) { + return "❌ /ungroup 需要飞书会话上下文。"; + } + const channel = ctx.feishuChannels.get(ctx.message.channel_id); + if (!channel) { + return "❌ 找不到对应的飞书 channel。"; + } + const senderOpenId = ctx.message.sender_open_id; + if (!senderOpenId) { + return "❌ 无法识别发命令的用户。"; + } + const isP2P = ctx.message.chat_type === "single"; + + // Decide which chat to dismiss. + let targetChatId: string; + let targetName: string; + if (isP2P) { + const query = ctx.args.join(" ").trim(); + if (!query) { + return "用法:`/ungroup <群名或 chat_id>`(单聊内必须指定目标)"; + } + const matches = channel.findBotGroupForCreator(query, senderOpenId); + if (matches.length === 0) { + return `❌ 没有找到你创建的群匹配 \`${query}\`。`; + } + if (matches.length > 1) { + const idList = matches + .map((m) => `- \`${m.chat_name}\` (\`${m.chat_id}\`)`) + .join("\n"); + return `⚠️ 有多个同名群,请用 chat_id 指定:\n${idList}`; + } + targetChatId = matches[0]!.chat_id; + targetName = matches[0]!.chat_name; + } else { + const chatId = ctx.message.chat_id; + if (!chatId) return "❌ 无法获取当前群 chat_id。"; + const row = channel.findBotGroup(chatId); + if (!row) { + return "❌ 当前群不是机器人创建的,拒绝解散。"; + } + if (row.creator_open_id !== senderOpenId) { + return "🚫 只有当初用 /group 建群的人才能解散它。"; + } + targetChatId = row.chat_id; + targetName = row.chat_name; + } + + try { + await channel.dismissChat(targetChatId); + } catch (err) { + ctx.logger.error( + { err, chat_id: targetChatId }, + "dismissChat failed", + ); + return `❌ 解散失败:${(err as Error).message}`; + } + channel.deleteBotGroupRecord(targetChatId); + return cardReply("解散群聊", [ + `✅ 已解散 \`${targetName}\` (\`${targetChatId}\`)。`, + ]); + }, +}; + +const allowHandler: CommandHandler = { + name: "allow", + description: "/allow @user1 @user2 ... — 把 @ 的人加到机器人白名单", + async execute(ctx) { + if (!ctx.message.channel_id) { + return "❌ /allow 需要飞书会话上下文。"; + } + const channel = ctx.feishuChannels.get(ctx.message.channel_id); + if (!channel) { + return "❌ 找不到对应的飞书 channel。"; + } + const senderOpenId = ctx.message.sender_open_id; + if (!senderOpenId) { + return "❌ 无法识别发命令的用户。"; + } + const mentions = ctx.message.mentions ?? []; + // Self-mention doesn't count — adding yourself is a no-op since you + // clearly already passed the whitelist gate to get here. + const targets = Array.from( + new Set( + mentions + .map((m) => m.open_id) + .filter((id) => id && id !== senderOpenId), + ), + ); + if (targets.length === 0) { + return "用法:`/allow @user1 @user2 ...`(至少 @ 一个人,不能 @ 自己)"; + } + let added: string[]; + try { + added = await channel.addToWhitelist(targets); + } catch (err) { + ctx.logger.error({ err, targets }, "addToWhitelist failed"); + return `❌ 写入白名单失败:${(err as Error).message}`; + } + if (added.length === 0) { + return "ℹ️ 所有指定用户已在白名单里,无需更新。"; + } + return cardReply("白名单已更新", [ + `✅ 新增 ${added.length} 人到白名单:`, + ...added.map((id) => `- \`${id}\``), + ]); + }, +}; + export const helpHandler: CommandHandler = { name: "help", description: "/help — 显示所有可用命令", @@ -349,6 +462,7 @@ export const helpHandler: CommandHandler = { "- /stop — 取消当前 session 正在执行的任务", "- /setup — 打开 workspace 配置卡片(仅群聊)", "- /switch — 打开 workspace 切换卡片(群聊 & 单聊)", + "- /group <群名> @user... — 机器人建群并自动 /setup(仅单聊)", ], }, ], @@ -364,6 +478,8 @@ export const BUILTIN_COMMANDS: CommandHandler[] = [ lsHandler, cloneHandler, checkoutHandler, + ungroupHandler, + allowHandler, ]; function _formatSyncLine(r: RepoSyncResult): string { diff --git a/src/kernel/commands/types.ts b/src/kernel/commands/types.ts index dd2cb6d..76b80b0 100644 --- a/src/kernel/commands/types.ts +++ b/src/kernel/commands/types.ts @@ -1,5 +1,6 @@ import type { Logger, UserMessage } from "@/shared"; +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; import type { Card } from "../../community/feishu/messaging/types"; import type { GroupWorkspaceStore } from "../workspaces"; @@ -19,6 +20,12 @@ export interface CommandContext { args: string[]; raw: string; workspaceStore: GroupWorkspaceStore; + /** + * All active Feishu channels, keyed by id. Handlers that need to call SDK + * methods (e.g. `/ungroup` deleting a chat, `/allow` mutating the + * whitelist) look up the originating channel via `message.channel_id`. + */ + feishuChannels: Map; logger: Logger; } diff --git a/src/kernel/group/group-flow.ts b/src/kernel/group/group-flow.ts new file mode 100644 index 0000000..28ace83 --- /dev/null +++ b/src/kernel/group/group-flow.ts @@ -0,0 +1,256 @@ +import type { DrizzleDB } from "@/data"; +import type { Logger } from "@/shared"; +import { createLogger, uuid, type UserMessage } from "@/shared"; + +import { feishuBotGroups } from "../../community/feishu/messaging/data"; +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; +import type { SetupFlow } from "../setup/setup-flow"; + +/** + * Orchestrates the `/group @user1 @user2` command. + * + * Flow: + * 1. Validate P2P-only + at least one @-mention. + * 2. Create a group via Feishu API with the sender + mentioned users. + * 3. Transfer ownership from the bot to the sender. + * 4. Persist the chat to `feishu_bot_groups` so `/ungroup` can look it up. + * 5. Post a welcome line in the new group and anchor an auto-triggered + * `/setup` card to it (so the new group is initialized for a workspace). + * 6. Confirm back to the P2P sender. + * + * One-shot: no pending state is tracked on the instance — all lookup data + * lives in the DB. + */ +export class GroupFlow { + private readonly _logger: Logger = createLogger("group-flow"); + private readonly _feishuChannels: Map; + private readonly _setupFlow: SetupFlow; + private readonly _db: DrizzleDB; + + constructor(deps: { + feishuChannels: Map; + setupFlow: SetupFlow; + db: DrizzleDB; + }) { + this._feishuChannels = deps.feishuChannels; + this._setupFlow = deps.setupFlow; + this._db = deps.db; + } + + async start(message: UserMessage): Promise { + if (message.chat_type !== "single") { + await this._replyText( + message, + "❌ /group 仅在与机器人的单聊中可用。", + ); + return; + } + if (!message.channel_id || !message.chat_id) { + await this._replyText(message, "❌ /group 缺少会话上下文。"); + return; + } + const senderOpenId = message.sender_open_id; + if (!senderOpenId) { + await this._replyText( + message, + "❌ 无法识别发命令的用户,请稍后重试。", + ); + return; + } + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) { + await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); + return; + } + + const parsed = this._parseArgs(message, senderOpenId, channel.botOpenId); + if ("error" in parsed) { + await this._replyText(message, parsed.error); + return; + } + const { name, memberOpenIds } = parsed; + + // The sender must be in the chat to become the owner; dedup in case + // they also @'d themselves. The bot is always auto-added by Feishu as + // the creator, no need to include it here. + const allMembers = Array.from(new Set([senderOpenId, ...memberOpenIds])); + + let chatId: string; + try { + chatId = await channel.createChat({ + name, + memberOpenIds: allMembers, + }); + } catch (err) { + this._logger.error( + { err, name, members: allMembers }, + "createChat failed", + ); + await this._replyText( + message, + `❌ 建群失败:${(err as Error).message}`, + ); + return; + } + + try { + await channel.transferChatOwner(chatId, senderOpenId); + } catch (err) { + // Non-fatal: the group exists and members are in. Warn the user so + // they know the bot is still owner and can transfer manually. + this._logger.error( + { err, chat_id: chatId, new_owner: senderOpenId }, + "transferChatOwner failed", + ); + await this._replyText( + message, + `⚠️ 群已建(\`${chatId}\`),但群主转让失败:${ + (err as Error).message + }。机器人仍是群主,可稍后手动转让。`, + ); + } + + this._db + .insert(feishuBotGroups) + .values({ + chat_id: chatId, + channel_id: channel.id, + chat_name: name, + creator_open_id: senderOpenId, + created_at: Date.now(), + }) + .onConflictDoNothing() + .run(); + + // Post a welcome line first so `/setup` has a real message_id to anchor + // its card reply to — SetupFlow.start reuses message.id as `replyTo`. + let welcomeMessageId: string; + try { + welcomeMessageId = await channel.sendPlainText( + chatId, + "✅ 群已建好,下面请填写 /setup 卡片初始化 workspace。", + ); + } catch (err) { + this._logger.error( + { err, chat_id: chatId }, + "sendPlainText failed; skipping auto-setup", + ); + await this._replyText( + message, + `✅ 已建群 \`${name}\` (\`${chatId}\`),但自动发 /setup 卡片失败:${ + (err as Error).message + }。请在群里 @ 机器人发 \`/setup\` 继续。`, + ); + return; + } + + const synthetic: UserMessage = { + id: welcomeMessageId, + session_id: uuid(), + role: "user", + channel_id: channel.id, + chat_id: chatId, + chat_type: "group", + sender_open_id: senderOpenId, + content: [{ type: "text", text: "" }], + }; + try { + await this._setupFlow.start(synthetic); + } catch (err) { + this._logger.error( + { err, chat_id: chatId }, + "auto /setup failed", + ); + await this._replyText( + message, + `✅ 已建群 \`${name}\`,但自动 /setup 失败:${ + (err as Error).message + }。请在群里 @ 机器人发 \`/setup\` 继续。`, + ); + return; + } + + await this._replyText( + message, + `✅ 已建群 \`${name}\`(${ + allMembers.length + } 人),并在群里发出 /setup 卡片。`, + ); + } + + private _parseArgs( + message: UserMessage, + senderOpenId: string, + botOpenId: string | undefined, + ): + | { name: string; memberOpenIds: string[] } + | { error: string } { + const mentions = message.mentions ?? []; + if (mentions.length === 0) { + return { + error: + "用法:`/group <群名> @user1 @user2 ...`(至少 @ 一个人)", + }; + } + const seen = new Set(); + const memberOpenIds: string[] = []; + for (const m of mentions) { + // Skip self — `/group` is issued by the sender, they're always in the + // new chat. Skip the bot too — Feishu auto-adds the creator, and + // we don't want `@bot /group …` to inflate the member count. + if (m.open_id === senderOpenId) continue; + if (botOpenId && m.open_id === botOpenId) continue; + if (seen.has(m.open_id)) continue; + seen.add(m.open_id); + memberOpenIds.push(m.open_id); + } + if (memberOpenIds.length === 0) { + return { error: "❌ 没有识别到被 @ 的其他成员(自己不算)。" }; + } + + // Name is everything between `/group ` and the first mention placeholder. + // Use the message's text content verbatim — mentions carry `key` + // substrings (e.g. `@_user_0`) so we can split on the earliest one. + const text = message.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(" ") + .trim(); + const stripped = text.replace(/^\/group\b/, "").trim(); + let earliestIdx = stripped.length; + for (const m of mentions) { + if (!m.key) continue; + const idx = stripped.indexOf(m.key); + if (idx >= 0 && idx < earliestIdx) earliestIdx = idx; + } + const rawName = stripped.slice(0, earliestIdx).trim(); + if (!rawName) { + return { + error: "❌ 群名不能为空。用法:`/group <群名> @user1 ...`", + }; + } + return { name: rawName, memberOpenIds }; + } + + private async _replyText( + message: UserMessage, + text: string, + ): Promise { + if (!message.channel_id) return; + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) return; + try { + await channel.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }, + { streaming: false, replyInThread: false }, + ); + } catch (err) { + this._logger.warn({ err }, "group-flow _replyText failed"); + } + } +} diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 8371359..84987e7 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -16,6 +16,7 @@ import { HonoServer } from "../server"; import { CommandRegistry, parseCommand, type CardCommandResult } from "./commands"; import { buildCommandCard } from "./commands/cards"; +import { GroupFlow } from "./group/group-flow"; import { MultiChannelMessageGateway } from "./messaging"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; @@ -41,6 +42,7 @@ class Kernel { private _feishuChannels = new Map(); private _setupFlow!: SetupFlow; private _switchFlow!: SwitchFlow; + private _groupFlow!: GroupFlow; constructor() { this._initDatabase(); @@ -51,6 +53,7 @@ class Kernel { this._initMessageGateway(); this._initSetupFlow(); this._initSwitchFlow(); + this._initGroupFlow(); this._initServer(); } @@ -154,6 +157,14 @@ class Kernel { }); } + private _initGroupFlow(): void { + this._groupFlow = new GroupFlow({ + feishuChannels: this._feishuChannels, + setupFlow: this._setupFlow, + db: this._database.db, + }); + } + /** * Start the kernel. */ @@ -198,6 +209,14 @@ class Kernel { return; } + // Handle /group command (kernel-owned — orchestrates create-chat + + // transfer-owner + auto /setup). Takes args, so match the prefix rather + // than equality. + if (text === "/group" || text.startsWith("/group ")) { + await this._groupFlow.start(message); + return; + } + // Try gateway-level slash commands before dispatching to the LLM. if (text.startsWith("/")) { const handled = await this._tryHandleCommand(message, text); @@ -267,6 +286,7 @@ class Kernel { args: parsed.args, raw: parsed.raw, workspaceStore: this._workspaceStore, + feishuChannels: this._feishuChannels, logger: this._logger, }); if (typeof result === "string") { diff --git a/src/shared/messaging/types/message.ts b/src/shared/messaging/types/message.ts index 420846f..9f184f6 100644 --- a/src/shared/messaging/types/message.ts +++ b/src/shared/messaging/types/message.ts @@ -39,6 +39,22 @@ export const SystemMessage = BaseMessage.extend({ }); export interface SystemMessage extends z.infer {} +/** + * A single @-mention inside an inbound message. Provider-agnostic shape — + * Feishu maps these from the event's `mentions` array; other channels may + * leave the list empty. Consumers (e.g. `/group`, `/allow`) use this to + * resolve `@_user_N` placeholders in the text content back to open_ids. + */ +export const MessageMention = z.object({ + /** Placeholder substring in the text, e.g. `@_user_0` for Feishu. */ + key: z.string(), + /** Provider-specific open_id of the mentioned user. */ + open_id: z.string(), + /** Display name at event time (best-effort). */ + name: z.string().optional(), +}); +export interface MessageMention extends z.infer {} + /** * The user message. */ @@ -58,6 +74,11 @@ export const UserMessage = BaseMessage.extend({ thread_id: z.string().optional(), /** Provider-specific open_id of the sender (e.g. Feishu open_id). */ sender_open_id: z.string().optional(), + /** + * @-mentions carried from the source event, in order. Empty/undefined when + * the channel doesn't expose mentions or when no users were @-tagged. + */ + mentions: z.array(MessageMention).optional(), content: z.array( z.discriminatedUnion("type", [ TextMessageContent, From f11c7941f093f84f17647dffa46f02c50c3563e6 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 10:25:29 +0800 Subject: [PATCH 23/69] feat(config): agents.env passes through to every agent spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host zshrc never fires because agentara spawns claude/codex directly with Bun.spawn — no interactive shell. To let users inject proxy vars, custom certs, feature flags, etc. at launch, add `agents.env: {..}` to config.yaml, merged into the spawn env of both runners. Precedence: Bun.env ← agents.env ← (codex isolationEnv) ← envExtras ← (claude ANTHROPIC_API_KEY blank). Workspace-level per-dispatch overrides still win; the host env stays the baseline; agents.env fills the gap the missing shell preamble used to cover. --- src/community/anthropic/claude-agent-runner.ts | 1 + src/community/openai/codex-agent-runner.ts | 1 + src/shared/config/schema.ts | 7 +++++++ 3 files changed, 9 insertions(+) diff --git a/src/community/anthropic/claude-agent-runner.ts b/src/community/anthropic/claude-agent-runner.ts index 00a8ed3..bfe8abf 100644 --- a/src/community/anthropic/claude-agent-runner.ts +++ b/src/community/anthropic/claude-agent-runner.ts @@ -53,6 +53,7 @@ export class ClaudeAgentRunner implements AgentRunner { cwd: options.cwd, env: { ...Bun.env, + ...config.agents.env, ...(options.envExtras ?? {}), ANTHROPIC_API_KEY: "", }, diff --git a/src/community/openai/codex-agent-runner.ts b/src/community/openai/codex-agent-runner.ts index 799c4c0..f1e91f9 100644 --- a/src/community/openai/codex-agent-runner.ts +++ b/src/community/openai/codex-agent-runner.ts @@ -75,6 +75,7 @@ export class CodexAgentRunner implements AgentRunner { cwd: options.cwd, env: { ...Bun.env, + ...config.agents.env, ...isolationEnv, ...(options.envExtras ?? {}), }, diff --git a/src/shared/config/schema.ts b/src/shared/config/schema.ts index 58c88bf..a2d4a52 100644 --- a/src/shared/config/schema.ts +++ b/src/shared/config/schema.ts @@ -28,10 +28,17 @@ export interface CodexConfig extends z.infer {} /** * Configuration for all agents. + * + * `env` is merged into every agent spawn's environment — both Claude and + * Codex. Use it to inject static variables the host shell wouldn't provide + * (proxy settings, custom certs, feature flags). It sits between `Bun.env` + * and per-dispatch `envExtras` in the precedence chain, so workspace-level + * overrides still win and the host env stays the baseline. */ export const AgentsConfig = z.object({ default: AgentConfig, codex: CodexConfig.default({ isolate_host_env: false }), + env: z.record(z.string(), z.string()).default({}), }); export interface AgentsConfig extends z.infer {} From 2740257e90d528e4bec785e9e432b8805ecbd848 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 11:28:56 +0800 Subject: [PATCH 24/69] feat(agents): runner registry + claude-gated / codex-gated plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously createAgentRunner was a hardcoded switch over the four built-in types. Extract the switch into a Map-based registry (registerRunner / createRunner / listRunnerTypes) so new runner types can self-register at module load without touching the factory. Plugins now live under src/plugins/ and are wired in by the kernel via a single side-effect import of the plugins barrel. TypeScript enforces the AgentRunner contract at build time — plugins that don't implement it won't compile. Two plugins land with the mechanism: - claude-gated: wraps ClaudeAgentRunner with the same preamble the author's zshrc runs before the real claude — pull the proxy out of agents.env, probe three IP-geolocation endpoints with a 2s timeout, and abort the dispatch when the egress country is not US (or every probe fails). The proxy is forwarded to the inner spawn via envExtras so a misconfigured agents.env doesn't silently disable egress. - codex-gated: symmetric wrapper over CodexAgentRunner, same gate. Enable by setting `agents.default.type: "claude-gated"` or `"codex-gated"` in config.yaml. The original `claude` / `codex` types stay registered; switching is instant. --- src/kernel/agents/factory.ts | 36 ++++++++-------- src/kernel/agents/index.ts | 1 + src/kernel/agents/registry.ts | 50 +++++++++++++++++++++ src/kernel/kernel.ts | 4 ++ src/plugins/_country-check.ts | 81 +++++++++++++++++++++++++++++++++++ src/plugins/claude-gated.ts | 79 ++++++++++++++++++++++++++++++++++ src/plugins/codex-gated.ts | 72 +++++++++++++++++++++++++++++++ src/plugins/index.ts | 12 ++++++ 8 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 src/kernel/agents/registry.ts create mode 100644 src/plugins/_country-check.ts create mode 100644 src/plugins/claude-gated.ts create mode 100644 src/plugins/codex-gated.ts create mode 100644 src/plugins/index.ts diff --git a/src/kernel/agents/factory.ts b/src/kernel/agents/factory.ts index c5b5e7d..25cd813 100644 --- a/src/kernel/agents/factory.ts +++ b/src/kernel/agents/factory.ts @@ -2,24 +2,26 @@ import { ClaudeAgentRunner } from "@/community/anthropic"; import { CodexAgentRunner } from "@/community/openai"; import { DummyAgentRunner, MockAgentRunner, type AgentRunner } from "@/shared"; +import { createRunner, registerRunner } from "./registry"; + +// Register the built-in runners at module load. Plugins under `src/plugins` +// are imported separately (see `src/plugins/index.ts`) and register +// themselves by the same mechanism. +registerRunner("claude", () => new ClaudeAgentRunner()); +registerRunner("codex", () => new CodexAgentRunner()); +registerRunner("dummy", () => new DummyAgentRunner()); +registerRunner( + "mock", + () => + new MockAgentRunner( + "user-home/sessions/34681283-bf20-4dc4-8301-a0929104002e.jsonl", + ), +); + /** - * Creates an agent runner based on the agent type. - * @param agentType The type of the agent. - * @returns The agent runner. + * Creates an agent runner based on the agent type. Thin wrapper over the + * registry for back-compat with existing call sites. */ export function createAgentRunner(agentType: string): AgentRunner { - switch (agentType) { - case "claude": - return new ClaudeAgentRunner(); - case "codex": - return new CodexAgentRunner(); - case "dummy": - return new DummyAgentRunner(); - case "mock": - return new MockAgentRunner( - "user-home/sessions/34681283-bf20-4dc4-8301-a0929104002e.jsonl", - ); - default: - throw new Error(`Unknown agent type: ${agentType}`); - } + return createRunner(agentType); } diff --git a/src/kernel/agents/index.ts b/src/kernel/agents/index.ts index 47d38b8..7f5d35e 100644 --- a/src/kernel/agents/index.ts +++ b/src/kernel/agents/index.ts @@ -1 +1,2 @@ export * from "./factory"; +export * from "./registry"; diff --git a/src/kernel/agents/registry.ts b/src/kernel/agents/registry.ts new file mode 100644 index 0000000..153d0c4 --- /dev/null +++ b/src/kernel/agents/registry.ts @@ -0,0 +1,50 @@ +import { createLogger, type AgentRunner } from "@/shared"; + +const _logger = createLogger("agent-registry"); + +/** + * Factory that produces a fresh `AgentRunner` instance. Deferred so + * registration can happen at module-load time without paying the cost of + * constructing runners that are never used. + */ +type RunnerFactory = () => AgentRunner; + +const _runners = new Map(); + +/** + * Register a runner under a type name. Later registrations with the same + * type overwrite — useful for plugins that wrap/replace a built-in. + */ +export function registerRunner(type: string, factory: RunnerFactory): void { + const overwrite = _runners.has(type); + _runners.set(type, factory); + if (overwrite) { + _logger.info({ type }, "runner type re-registered (overwrite)"); + } else { + _logger.info({ type }, "runner type registered"); + } +} + +/** + * Create a runner instance for the given type. Throws when no runner is + * registered under the name — the caller is expected to surface a clear + * error to the user (e.g. "check `agents.default.type` in config.yaml"). + */ +export function createRunner(type: string): AgentRunner { + const factory = _runners.get(type); + if (!factory) { + const known = Array.from(_runners.keys()).join(", ") || ""; + throw new Error( + `Unknown agent runner type: \`${type}\`. Known types: ${known}.`, + ); + } + return factory(); +} + +/** + * List currently-registered runner types. Intended for diagnostics / + * `/help`-style surfaces; order is insertion order. + */ +export function listRunnerTypes(): string[] { + return Array.from(_runners.keys()); +} diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 84987e7..505a7ba 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -2,6 +2,10 @@ import { FeishuMessageChannel } from "@/community/feishu"; import * as feishuMessagingSchema from "@/community/feishu/messaging/data"; import type { Card } from "@/community/feishu/messaging/types"; import { DataConnection } from "@/data"; +// Side-effect import: every module under `src/plugins/*` registers its +// runner with the registry at load time. This must happen before any +// session dispatch so `agents.default.type` can resolve plugin types. +import "@/plugins"; import type { AssistantMessage, CardActionPayload, UserMessage } from "@/shared"; import { config, diff --git a/src/plugins/_country-check.ts b/src/plugins/_country-check.ts new file mode 100644 index 0000000..845755c --- /dev/null +++ b/src/plugins/_country-check.ts @@ -0,0 +1,81 @@ +import { createLogger } from "@/shared"; + +const _logger = createLogger("country-check"); + +/** + * Endpoints that return the caller's country as a 2-letter ISO code. Tried + * in order with a short timeout; the first successful response wins. All + * three return plain text (or JSON we can regex), so no SDK dependency. + * + * Mirrors the zshrc preamble so agentara spawns fire under the same guard: + * if the outbound proxy isn't landing somewhere expected, we bail loudly + * before the agent starts billing tokens. + */ +const COUNTRY_PROBES = [ + { url: "https://ipapi.co/country/", kind: "text" as const }, + { url: "https://ifconfig.co/country-iso", kind: "text" as const }, + { url: "https://api.country.is/", kind: "country_is_json" as const }, +]; + +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Probe several IP-geolocation endpoints and return the first ISO country + * code they agree on shape-wise. Returns `null` when every probe fails + * (network down, all providers rate-limited, etc.) — callers distinguish + * that from a successful detection with `country !== "US"`. + */ +export async function detectCountry(options?: { + /** Proxy URL (e.g. `http://127.0.0.1:7897`). Skipped when undefined. */ + proxy?: string; + /** Per-request timeout. Defaults to 2s, matching the zshrc curl. */ + timeoutMs?: number; +}): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const proxy = options?.proxy; + + for (const probe of COUNTRY_PROBES) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res: Response; + try { + // Bun's `fetch` accepts a `proxy` option natively — no undici + // ProxyAgent setup needed. The signal handles the timeout. + res = await fetch(probe.url, { + signal: controller.signal, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun's proxy option isn't in lib.dom.fetch yet + ...(proxy ? ({ proxy } as any) : {}), + }); + } finally { + clearTimeout(timer); + } + if (!res.ok) continue; + const text = await res.text(); + const parsed = _parseCountry(text, probe.kind); + if (parsed) { + _logger.debug( + { probe: probe.url, country: parsed }, + "country detected", + ); + return parsed; + } + } catch (err) { + _logger.debug({ err, probe: probe.url }, "country probe failed"); + } + } + return null; +} + +function _parseCountry( + body: string, + kind: "text" | "country_is_json", +): string | null { + if (kind === "country_is_json") { + const match = body.match(/"country"\s*:\s*"([A-Z]{2})"/); + return match ? match[1]! : null; + } + // Plain text: strip whitespace, take first 2 uppercase chars. + const head = body.trim().slice(0, 2).toUpperCase(); + return /^[A-Z]{2}$/.test(head) ? head : null; +} diff --git a/src/plugins/claude-gated.ts b/src/plugins/claude-gated.ts new file mode 100644 index 0000000..f74b186 --- /dev/null +++ b/src/plugins/claude-gated.ts @@ -0,0 +1,79 @@ +import { ClaudeAgentRunner } from "@/community/anthropic"; +import { registerRunner } from "@/kernel/agents"; +import { + config, + createLogger, + type AgentRunOptions, + type AgentRunner, + type AssistantMessage, + type SystemMessage, + type ToolMessage, + type UserMessage, +} from "@/shared"; + +import { detectCountry } from "./_country-check"; + +const _logger = createLogger("claude-gated"); + +/** + * Wraps {@link ClaudeAgentRunner} with the safety preamble the user runs + * in zshrc before invoking the real `claude` CLI: + * + * 1. Resolve a proxy URL (from `agents.env.HTTPS_PROXY` / `HTTP_PROXY`) + * and use it both for the country-detection fetch and for the + * delegated spawn's env. + * 2. Call the IP-geolocation probes in {@link detectCountry} with a + * short timeout. Abort the dispatch if the country is not `US` or if + * every probe failed — we'd rather raise a clear error than let the + * agent burn tokens against a blocked egress. + * 3. Delegate to the built-in Claude runner, carrying the proxy through + * via `envExtras` so the inner spawn actually goes through it. + * + * Enable by setting `agents.default.type: "claude-gated"` in `config.yaml`. + */ +class ClaudeGatedRunner implements AgentRunner { + readonly type = "claude-gated"; + private readonly _inner = new ClaudeAgentRunner(); + + async *stream( + message: UserMessage, + options: AgentRunOptions, + ): AsyncIterableIterator { + const proxy = _resolveProxy(); + + const country = await detectCountry({ proxy }); + if (country === null) { + throw new Error( + "无法判定当前出口 IP 所在国家/地区,已拦截 Claude 启动(claude-gated)。", + ); + } + if (country !== "US") { + throw new Error( + `检测到当前出口 IP 不在美国(country=${country}),已拦截 Claude 启动(claude-gated)。`, + ); + } + _logger.info({ country }, "country gate passed"); + + const mergedOptions: AgentRunOptions = proxy + ? { + ...options, + envExtras: { + ...(options.envExtras ?? {}), + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + }, + } + : options; + + yield* this._inner.stream(message, mergedOptions); + } +} + +function _resolveProxy(): string | undefined { + const envMap = config.agents.env ?? {}; + return ( + envMap.HTTPS_PROXY ?? envMap.HTTP_PROXY ?? envMap.https_proxy ?? envMap.http_proxy + ); +} + +registerRunner("claude-gated", () => new ClaudeGatedRunner()); diff --git a/src/plugins/codex-gated.ts b/src/plugins/codex-gated.ts new file mode 100644 index 0000000..d2cb36e --- /dev/null +++ b/src/plugins/codex-gated.ts @@ -0,0 +1,72 @@ +import { CodexAgentRunner } from "@/community/openai"; +import { registerRunner } from "@/kernel/agents"; +import { + config, + createLogger, + type AgentRunOptions, + type AgentRunner, + type AssistantMessage, + type SystemMessage, + type ToolMessage, + type UserMessage, +} from "@/shared"; + +import { detectCountry } from "./_country-check"; + +const _logger = createLogger("codex-gated"); + +/** + * Symmetric counterpart to {@link import("./claude-gated").default}: same + * country-gate + proxy-injection preamble, wrapping Codex instead of + * Claude. Codex already ships with `--dangerously-bypass-approvals-and-sandbox` + * baked in at the built-in runner level, so no extra CLI flag layering is + * needed here — the plugin's job is purely egress guarding. + * + * Enable by setting `agents.default.type: "codex-gated"` in `config.yaml`. + */ +class CodexGatedRunner implements AgentRunner { + readonly type = "codex-gated"; + private readonly _inner = new CodexAgentRunner(); + + async *stream( + message: UserMessage, + options: AgentRunOptions, + ): AsyncIterableIterator { + const proxy = _resolveProxy(); + + const country = await detectCountry({ proxy }); + if (country === null) { + throw new Error( + "无法判定当前出口 IP 所在国家/地区,已拦截 Codex 启动(codex-gated)。", + ); + } + if (country !== "US") { + throw new Error( + `检测到当前出口 IP 不在美国(country=${country}),已拦截 Codex 启动(codex-gated)。`, + ); + } + _logger.info({ country }, "country gate passed"); + + const mergedOptions: AgentRunOptions = proxy + ? { + ...options, + envExtras: { + ...(options.envExtras ?? {}), + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + }, + } + : options; + + yield* this._inner.stream(message, mergedOptions); + } +} + +function _resolveProxy(): string | undefined { + const envMap = config.agents.env ?? {}; + return ( + envMap.HTTPS_PROXY ?? envMap.HTTP_PROXY ?? envMap.https_proxy ?? envMap.http_proxy + ); +} + +registerRunner("codex-gated", () => new CodexGatedRunner()); diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 0000000..8c01ad6 --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,12 @@ +/** + * Plugin barrel — importing this module side-effects registers every + * plugin under `src/plugins/*` with the agent runner registry. Add new + * plugins by creating a module that calls `registerRunner(...)` at + * top-level and appending it to this barrel. + * + * The kernel imports this once at startup (see `kernel.ts`), so by the + * time the first session dispatches, every plugin runner is resolvable + * via `agents.default.type` in config.yaml. + */ +import "./claude-gated"; +import "./codex-gated"; From 00335f1e7022ab86eb6199c4fd65388cc588d719 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 11:33:26 +0800 Subject: [PATCH 25/69] feat(config): agents.default.model is optional; fall back to CLI default Having a required `model` with a Claude-flavored default value baked into the schema silently produced a mis-match when the default type was codex (e.g. `--model claude-sonnet-4-6` passed to Codex). Drop the schema default and gate the `--model` flag on the value being set, so omitting `model` from config.yaml leaves model selection to the runner's own CLI (`claude` / `codex`). The bootstrap template is updated too, with the example commented out to make the "optional" stance obvious. --- src/boot-loader/boot-loader.ts | 2 +- src/community/anthropic/claude-agent-runner.ts | 5 ++++- src/community/openai/codex-agent-runner.ts | 5 ++++- src/shared/config/schema.ts | 7 ++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/boot-loader/boot-loader.ts b/src/boot-loader/boot-loader.ts index d721e47..5df4ace 100644 --- a/src/boot-loader/boot-loader.ts +++ b/src/boot-loader/boot-loader.ts @@ -98,7 +98,7 @@ class BootLoader { agents: default: type: claude - model: claude-sonnet-4-6 + # model: claude-sonnet-4-6 # optional; omit to use the CLI's default codex: isolate_host_env: false diff --git a/src/community/anthropic/claude-agent-runner.ts b/src/community/anthropic/claude-agent-runner.ts index bfe8abf..549d1af 100644 --- a/src/community/anthropic/claude-agent-runner.ts +++ b/src/community/anthropic/claude-agent-runner.ts @@ -40,10 +40,13 @@ export class ClaudeAgentRunner implements AgentRunner { extractTextContent(message), ); + const configuredModel = config.agents.default.model; const args = [ "claude", ...(!isNew ? ["--resume", sessionId] : ["--session-id", sessionId]), - ...["--model", config.agents.default.model], + // Only pin the model when config names one; otherwise Claude CLI + // picks its own default (user omitted `model` in config.yaml). + ...(configuredModel ? ["--model", configuredModel] : []), ...["--output-format", "stream-json"], "--print", "--verbose", diff --git a/src/community/openai/codex-agent-runner.ts b/src/community/openai/codex-agent-runner.ts index f1e91f9..db7c897 100644 --- a/src/community/openai/codex-agent-runner.ts +++ b/src/community/openai/codex-agent-runner.ts @@ -490,10 +490,13 @@ export class CodexAgentRunner implements AgentRunner { resumeId: string; prompt: string; }): string[] { + const configuredModel = config.agents.default.model; const shared = [ "codex", "exec", - ...["--model", config.agents.default.model], + // Only pin the model when config names one; otherwise Codex CLI + // picks its own default (user omitted `model` in config.yaml). + ...(configuredModel ? ["--model", configuredModel] : []), "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", diff --git a/src/shared/config/schema.ts b/src/shared/config/schema.ts index a2d4a52..cfe1d2c 100644 --- a/src/shared/config/schema.ts +++ b/src/shared/config/schema.ts @@ -2,10 +2,15 @@ import { z } from "zod"; /** * Configuration for a single agent. + * + * `model` is optional on purpose — when unset, the runner skips the + * `--model` CLI flag entirely so the underlying tool (Claude Code, Codex) + * picks its own default. This sidesteps the "one model key for two CLIs" + * problem where e.g. `claude-sonnet-4-6` is nonsense to Codex. */ export const AgentConfig = z.object({ type: z.string(), - model: z.string().default("claude-sonnet-4-6"), + model: z.string().optional(), }); export interface AgentConfig extends z.infer {} From e527f14c6d0d7874e1018f840198d7bb75953813 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 11:39:15 +0800 Subject: [PATCH 26/69] docs(workspace): prefer Markdown bold over HTML in Feishu cards `...` doesn't render reliably in Feishu interactive cards, so steer the agent toward `**text**` for emphasis while keeping `` scoped to color only. Adds an explicit combo example for the color+emphasis case. --- user-home/CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/user-home/CLAUDE.md b/user-home/CLAUDE.md index c6973a8..e40c3c9 100644 --- a/user-home/CLAUDE.md +++ b/user-home/CLAUDE.md @@ -64,7 +64,9 @@ Dense, telegraphic short sentences. No filler words ("You are", "You should", "Y ## Messaging Conventions -- Use {text} to format text with color. Use color and bold to highlight important text and figures. +- Use `{text}` only for text color. +- Use Markdown `**text**` for emphasis. Do not use HTML bold tags such as `...`. +- When you need both color and emphasis, wrap Markdown emphasis inside the font tag, for example `**important**`. - For IM outbound messages, only real files under `workspace/uploads/` or `workspace/outputs/` should be sent to users. Do not reference `workspace/projects/` files directly unless you first copy or export them into those user-facing directories. - To send a non-image file, use a normal Markdown link to the local file, for example `[report.pdf](workspace/outputs/reports/report.pdf)`. - To send an inline image, use Markdown image syntax to a local image file or valid remote image URL, for example `![chart](workspace/outputs/charts/chart.png)`. From 83334475a4d3037863814edeabb6d7f1d5850579 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 17:12:49 +0800 Subject: [PATCH 27/69] feat(agents): claude-gated auto-skips Claude CLI permissions Adds `dangerouslySkipPermissions` to AgentRunOptions and forces it on inside claude-gated so the unattended robot flow stops stalling on per-tool approvals. Base ClaudeAgentRunner keeps the safe default. --- .../anthropic/claude-agent-runner.ts | 3 ++ src/plugins/claude-gated.ts | 28 +++++++++++-------- src/shared/agents/agent-runner.ts | 7 +++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/community/anthropic/claude-agent-runner.ts b/src/community/anthropic/claude-agent-runner.ts index 549d1af..95a75f0 100644 --- a/src/community/anthropic/claude-agent-runner.ts +++ b/src/community/anthropic/claude-agent-runner.ts @@ -47,6 +47,9 @@ export class ClaudeAgentRunner implements AgentRunner { // Only pin the model when config names one; otherwise Claude CLI // picks its own default (user omitted `model` in config.yaml). ...(configuredModel ? ["--model", configuredModel] : []), + ...(options.dangerouslySkipPermissions + ? ["--dangerously-skip-permissions"] + : []), ...["--output-format", "stream-json"], "--print", "--verbose", diff --git a/src/plugins/claude-gated.ts b/src/plugins/claude-gated.ts index f74b186..3fee24b 100644 --- a/src/plugins/claude-gated.ts +++ b/src/plugins/claude-gated.ts @@ -27,7 +27,10 @@ const _logger = createLogger("claude-gated"); * every probe failed — we'd rather raise a clear error than let the * agent burn tokens against a blocked egress. * 3. Delegate to the built-in Claude runner, carrying the proxy through - * via `envExtras` so the inner spawn actually goes through it. + * via `envExtras` so the inner spawn actually goes through it, and + * force `--dangerously-skip-permissions` on — this wrapper exists for + * the unattended robot flow where the agent runs inside a controlled + * workspace, so per-tool approvals just stall the pipeline. * * Enable by setting `agents.default.type: "claude-gated"` in `config.yaml`. */ @@ -54,16 +57,19 @@ class ClaudeGatedRunner implements AgentRunner { } _logger.info({ country }, "country gate passed"); - const mergedOptions: AgentRunOptions = proxy - ? { - ...options, - envExtras: { - ...(options.envExtras ?? {}), - HTTP_PROXY: proxy, - HTTPS_PROXY: proxy, - }, - } - : options; + const mergedOptions: AgentRunOptions = { + ...options, + dangerouslySkipPermissions: true, + ...(proxy + ? { + envExtras: { + ...(options.envExtras ?? {}), + HTTP_PROXY: proxy, + HTTPS_PROXY: proxy, + }, + } + : {}), + }; yield* this._inner.stream(message, mergedOptions); } diff --git a/src/shared/agents/agent-runner.ts b/src/shared/agents/agent-runner.ts index b245a54..02b4571 100644 --- a/src/shared/agents/agent-runner.ts +++ b/src/shared/agents/agent-runner.ts @@ -38,6 +38,13 @@ export const AgentRunOptions = z.object({ * When aborted, the agent runner should kill any spawned subprocesses. */ signal: z.instanceof(AbortSignal).optional(), + + /** + * When `true`, the Claude CLI is spawned with + * `--dangerously-skip-permissions` so every tool call is auto-approved. + * Off by default — only the gated/robot-facing wrappers should set it. + */ + dangerouslySkipPermissions: z.boolean().optional(), }); export interface AgentRunOptions extends z.infer {} From 519f7fc2b458e1ab809848dcfacef01caa6eb3c4 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 18:03:47 +0800 Subject: [PATCH 28/69] feat(feishu): spill overflow cards into a chain instead of failing Long agent runs were hitting Feishu's card-content caps mid-stream. Observed failure modes: - container `elements` > 50 -> error 11310 `element exceeds the limit` - a single 10 KB+ Bash `description` would push the card JSON toward the 30 KB body ceiling even below the element cap Replaces the previous best-effort truncation with a pre-flight splitter that walks the message content, caps each chunk at 25 steps AND ~20 KB of estimated rendered bytes, and keeps any single oversized step in its own chunk rather than dropping data. The channel now tracks a CardChain per logical assistant message: - cards[0] stays the caller-visible anchor id - filled cards are PATCHed once into a frozen state then never touched - the trailing card absorbs live updates until it fills, at which point it freezes and a continuation card is posted as a reply Non-streaming final state locks the whole chain so late updates become no-ops. The 400 fallback path is preserved as defense-in-depth. --- .../feishu/messaging/message-channel.ts | 354 +++++++++++------- .../feishu/messaging/message-renderer.ts | 135 ++++++- .../community/feishu/message-renderer.test.ts | 142 +++++++ 3 files changed, 500 insertions(+), 131 deletions(-) create mode 100644 tests/community/feishu/message-renderer.test.ts diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index fde9dcb..9f3ff9e 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -20,10 +20,34 @@ import { import { feishuBotGroups, feishuThreads } from "./data"; -import { renderMessageCard, splitMarkdownByTables } from "./message-renderer"; +import { + renderMessageCard, + splitMarkdownByTables, + splitMessageContentForCards, +} from "./message-renderer"; import type { Card, MessageReceiveEventData } from "./types"; import { convertPostToMarkdown } from "./utils"; +/** + * A chain of Feishu cards that together render a single logical assistant + * message. The channel splits overflow-prone content (many tool steps, + * table-heavy markdown) into multiple cards pre-flight rather than + * retrying after Feishu rejects the PATCH. + * + * Invariants: + * - `cards[0]` is the anchor message_id, exposed to callers as + * `AssistantMessage.id`. + * - `cards[0..finalized-1]` are frozen: fully rendered in non-streaming + * form; we never PATCH them again. + * - `cards[cards.length - 1]` (when `finalized < cards.length`) is the + * active card receiving live updates. + */ +interface CardChain { + cards: string[]; + finalized: number; + replyInThread: boolean; +} + function _isFeishuBadRequestError(err: unknown): boolean { if (!err || typeof err !== "object") { return false; @@ -59,6 +83,7 @@ export class FeishuMessageChannel private _client: Client; private _db: DrizzleDB; private _failedCardUpdateMessages = new Set(); + private _cardChains = new Map(); private _logger: Logger; private _requireMention: boolean; private _botOpenId?: string; @@ -558,97 +583,97 @@ export class FeishuMessageChannel replyInThread = true, }: { streaming?: boolean; replyInThread?: boolean } = {}, ): Promise { - const { firstMessageContent, remainingChunks } = this._prepareMessageContent( - message.content, - streaming, - ); - - const card = await renderMessageCard(firstMessageContent, { - streaming, - uploadImage: this.uploadImage.bind(this), - }); + const chunks = this._splitIntoCardChunks(message.content, streaming); if (!streaming) { this._logOutboundMessage(message.session_id, message.content); } + + // Post the primary (first chunk) as a reply to the user's message. + const primaryCard = await this._renderChunk(chunks[0]!, { + streaming: streaming && chunks.length === 1, + }); const { data: replyMessage } = await this._client.im.message.reply({ - path: { - message_id: messageId, - }, + path: { message_id: messageId }, data: { msg_type: "interactive", - content: JSON.stringify(card), + content: JSON.stringify(primaryCard), reply_in_thread: replyInThread, }, }); - if (!replyMessage) { + if (!replyMessage?.message_id) { throw new Error("Failed to reply message"); } + const anchorId = replyMessage.message_id; - if (replyInThread) { - const { thread_id: threadId } = replyMessage; - if (threadId) { - this._mapThreadToSession(threadId, message.session_id); - } + if (replyInThread && replyMessage.thread_id) { + this._mapThreadToSession(replyMessage.thread_id, message.session_id); } - await this._sendRemainingChunks( - replyMessage.message_id!, - remainingChunks, + const chain: CardChain = { + cards: [anchorId], + finalized: 0, replyInThread, - ); + }; + await this._growChainToFit(chain, chunks, streaming); + if (!streaming) { + chain.finalized = chain.cards.length; + } + this._cardChains.set(anchorId, chain); const assistantMessage = message as AssistantMessage; - assistantMessage.id = replyMessage.message_id!; + assistantMessage.id = anchorId; if (!streaming) { - const lastText = message.content.filter((c) => c.type === "text").pop(); - if (lastText?.type === "text") { - await this._sendLocalFileAttachments( - assistantMessage.id, - lastText.text, - ); - } + await this._sendFileAttachmentsForFinalText( + assistantMessage.id, + message.content, + ); } - return assistantMessage; } async postMessage( message: Omit, ): Promise { - const { firstMessageContent, remainingChunks } = this._prepareMessageContent( - message.content, - false, - ); + const chunks = this._splitIntoCardChunks(message.content, false); + this._logOutboundMessage(message.session_id, message.content); - const card = await renderMessageCard(firstMessageContent, { + const primaryCard = await this._renderChunk(chunks[0]!, { streaming: false, - uploadImage: this.uploadImage.bind(this), }); - this._logOutboundMessage(message.session_id, message.content); const { data } = await this._client.im.message.create({ - params: { - receive_id_type: "chat_id", - }, + params: { receive_id_type: "chat_id" }, data: { receive_id: this.config.chatId, msg_type: "interactive", - content: JSON.stringify(card), + content: JSON.stringify(primaryCard), }, }); - if (!data) { + if (!data?.message_id) { throw new Error("Failed to post message"); } - const { message_id: messageId } = data; - const assistantMessage = message as AssistantMessage; - assistantMessage.id = messageId!; + const anchorId = data.message_id; + + const chain: CardChain = { + cards: [anchorId], + finalized: 0, + // post-then-reply continuation cards attach to the primary with + // reply_in_thread:true, matching the pre-chain behavior. + replyInThread: true, + }; + await this._growChainToFit(chain, chunks, /* streaming */ false); + // postMessage is always a final one-shot — lock the whole chain so the + // primary (created outside _growChainToFit) is counted as finalized too. + chain.finalized = chain.cards.length; + this._cardChains.set(anchorId, chain); - await this._sendRemainingChunks(assistantMessage.id, remainingChunks); + const assistantMessage = message as AssistantMessage; + assistantMessage.id = anchorId; - const lastText = message.content.filter((c) => c.type === "text").pop(); - if (lastText?.type === "text") { - await this._sendLocalFileAttachments(assistantMessage.id, lastText.text); - } + await this._sendFileAttachmentsForFinalText( + assistantMessage.id, + message.content, + ); const emojis = [ "思考中", @@ -659,9 +684,7 @@ export class FeishuMessageChannel "挥手", ]; const { data: replyData } = await this._client.im.message.reply({ - path: { - message_id: assistantMessage.id, - }, + path: { message_id: assistantMessage.id }, data: { content: JSON.stringify({ type: "text", @@ -671,10 +694,8 @@ export class FeishuMessageChannel reply_in_thread: true, }, }); - if (replyData) { - const { thread_id: threadId } = replyData; - const sessionId = message.session_id; - this._mapThreadToSession(threadId!, sessionId); + if (replyData?.thread_id) { + this._mapThreadToSession(replyData.thread_id, message.session_id); } return assistantMessage; } @@ -688,27 +709,58 @@ export class FeishuMessageChannel return; } - const { firstMessageContent, remainingChunks } = this._prepareMessageContent( - message.content, - streaming, - ); - - const card = await renderMessageCard(firstMessageContent, { - streaming, - uploadImage: this.uploadImage.bind(this), - }); + const chain = + this._cardChains.get(message.id) ?? + // Lazy-init: a channel restart can lose in-memory chain state. Treat + // the incoming `message.id` as the anchor of a fresh single-card chain + // and let `_growChainToFit` append continuations as needed. + ({ + cards: [message.id], + finalized: 0, + replyInThread: true, + } as CardChain); + + const chunks = this._splitIntoCardChunks(message.content, streaming); if (!streaming) { this._logOutboundMessage(message.session_id, message.content); } + try { - await this._client.im.message.patch({ - path: { - message_id: message.id, - }, - data: { - content: JSON.stringify(card), - }, - }); + // Freeze any previously-active cards that are no longer the last in + // the chain (new step cards have been added after them). + while (chain.finalized < Math.min(chunks.length - 1, chain.cards.length)) { + const idx = chain.finalized; + const frozenCard = await this._renderChunk(chunks[idx]!, { + streaming: false, + }); + await this._patchCard(chain.cards[idx]!, frozenCard); + chain.finalized++; + } + + const initialLen = chain.cards.length; + await this._growChainToFit(chain, chunks, streaming); + + // If we didn't grow the chain this round, the active (last) card + // still needs a refresh to pick up the new steps. Skip when the last + // card is already frozen, or when the chunk it was rendered from no + // longer exists (defensive — content isn't expected to shrink). + const lastIdx = chain.cards.length - 1; + if ( + chain.cards.length === initialLen && + lastIdx >= 0 && + chain.finalized <= lastIdx && + lastIdx < chunks.length + ) { + const card = await this._renderChunk(chunks[lastIdx]!, { streaming }); + await this._patchCard(chain.cards[lastIdx]!, card); + } + // Final state reached — lock the whole chain so any stray later calls + // are no-ops rather than redundant PATCHes. + if (!streaming) { + chain.finalized = chain.cards.length; + } + + this._cardChains.set(message.id, chain); } catch (err) { if (_isFeishuBadRequestError(err)) { this._failedCardUpdateMessages.add(message.id); @@ -722,13 +774,8 @@ export class FeishuMessageChannel throw err; } - await this._sendRemainingChunks(message.id, remainingChunks); - if (!streaming) { - const lastText = message.content.filter((c) => c.type === "text").pop(); - if (lastText?.type === "text") { - await this._sendLocalFileAttachments(message.id, lastText.text); - } + await this._sendFileAttachmentsForFinalText(message.id, message.content); } } @@ -865,63 +912,114 @@ export class FeishuMessageChannel } /** - * Prepare message content for sending, splitting if necessary due to table limits. - * @param content - Original message content. - * @param streaming - Whether the message is being streamed (skip splitting if true). - * @returns First chunk content and remaining chunks to send as follow-ups. + * Split assistant message content into a list of card-ready chunks. + * + * Stage 1 — step panel overflow: spread thinking/tool_use blocks across + * chunks capped at {@link MAX_STEPS_PER_CARD} so no single panel exceeds + * Feishu's 50-element container cap. + * + * Stage 2 — markdown table overflow (non-streaming only): the final + * answer lives on the last step chunk; if it carries more than Feishu's + * 5-tables-per-card limit, the surplus gets peeled off into text-only + * continuation chunks appended after the last step chunk. + * + * During streaming we skip stage 2 because the final text isn't present + * yet (and any interim text is ephemeral). */ - private _prepareMessageContent( + private _splitIntoCardChunks( content: AssistantMessage["content"], streaming: boolean, - ): { - firstMessageContent: AssistantMessage["content"]; - remainingChunks: string[]; - } { - const lastTextContent = content.findLast((c) => c.type === "text"); - const markdownChunks = lastTextContent - ? splitMarkdownByTables(lastTextContent.text) - : []; - const needsSplit = !streaming && markdownChunks.length > 1; - - const firstMessageContent = needsSplit - ? (content.map((c) => - c.type === "text" ? { ...c, text: markdownChunks[0] } : c, - ) as AssistantMessage["content"]) - : content; - - const remainingChunks = needsSplit ? markdownChunks.slice(1) : []; - - return { firstMessageContent, remainingChunks }; + ): AssistantMessage["content"][] { + const chunks = splitMessageContentForCards(content); + if (streaming) { + return chunks; + } + const last = chunks[chunks.length - 1]!; + const lastTextIdx = last.findLastIndex((c) => c.type === "text"); + if (lastTextIdx === -1) return chunks; + const lastText = last[lastTextIdx]!; + if (lastText.type !== "text") return chunks; + const textChunks = splitMarkdownByTables(lastText.text); + if (textChunks.length <= 1) return chunks; + const rewrittenLast = [...last]; + rewrittenLast[lastTextIdx] = { ...lastText, text: textChunks[0]! }; + chunks[chunks.length - 1] = rewrittenLast as AssistantMessage["content"]; + for (let i = 1; i < textChunks.length; i++) { + chunks.push([ + { type: "text", text: textChunks[i]! }, + ] as AssistantMessage["content"]); + } + return chunks; + } + + /** Render a single card-chunk. */ + private async _renderChunk( + chunk: AssistantMessage["content"], + { streaming }: { streaming: boolean }, + ): Promise { + return renderMessageCard(chunk, { + streaming, + uploadImage: this.uploadImage.bind(this), + }); + } + + /** PATCH an existing Feishu card with a new body. */ + private async _patchCard(messageId: string, card: Card): Promise { + await this._client.im.message.patch({ + path: { message_id: messageId }, + data: { content: JSON.stringify(card) }, + }); } /** - * Send remaining markdown chunks as follow-up reply messages. - * @param messageId - The message ID to reply to. - * @param chunks - Array of markdown strings to send. + * Extend {@link chain} so it has a Feishu card for every chunk in + * {@link chunks}. Newly created non-last chunks are posted in frozen + * form and immediately marked finalized — we won't touch them again. + * The trailing chunk is posted in its current streaming state so it + * keeps receiving live updates. */ - private async _sendRemainingChunks( - messageId: string, - chunks: string[], - replyInThread = true, + private async _growChainToFit( + chain: CardChain, + chunks: AssistantMessage["content"][], + streaming: boolean, ): Promise { - for (const chunkText of chunks) { - const chunkCard = await renderMessageCard( - [{ type: "text", text: chunkText }], - { - streaming: false, - uploadImage: this.uploadImage.bind(this), - }, - ); - await this._client.im.message.reply({ - path: { - message_id: messageId, - }, + while (chain.cards.length < chunks.length) { + const idx = chain.cards.length; + const isLast = idx === chunks.length - 1; + const card = await this._renderChunk(chunks[idx]!, { + streaming: streaming && isLast, + }); + // Continuation cards hang off the anchor so Feishu renders them as + // siblings within the same topic thread. Replying to the previous + // continuation would nest them infinitely. + const { data } = await this._client.im.message.reply({ + path: { message_id: chain.cards[0]! }, data: { msg_type: "interactive", - content: JSON.stringify(chunkCard), - reply_in_thread: replyInThread, + content: JSON.stringify(card), + reply_in_thread: chain.replyInThread, }, }); + if (!data?.message_id) { + throw new Error("Failed to post continuation card"); + } + chain.cards.push(data.message_id); + // Finalize non-last cards unconditionally, and the trailing card too + // when the whole message is non-streaming (no more updates coming). + if (!isLast || !streaming) { + chain.finalized++; + } + } + } + + /** Send file attachments referenced in the final text block, if any. */ + private async _sendFileAttachmentsForFinalText( + messageId: string, + content: AssistantMessage["content"], + ): Promise { + const lastText = content.filter((c) => c.type === "text").pop(); + if (lastText?.type === "text") { + await this._sendLocalFileAttachments(messageId, lastText.text); } } diff --git a/src/community/feishu/messaging/message-renderer.ts b/src/community/feishu/messaging/message-renderer.ts index 9761a1b..12014c0 100644 --- a/src/community/feishu/messaging/message-renderer.ts +++ b/src/community/feishu/messaging/message-renderer.ts @@ -23,6 +23,26 @@ import type { MarkdownElement, } from "./types"; +/** + * Maximum step-panel elements per card. A single Feishu container is capped + * at 50 elements server-side (error 11310 `element exceeds the limit`); we + * keep cards well under that for UX breathing room and let the channel + * spill overflow into a follow-up card instead of truncating. + */ +export const MAX_STEPS_PER_CARD = 25; + +/** + * Per-card step-panel byte budget used by the content splitter. + * + * Feishu caps a card's JSON `content` field at ~30 KB. Subtract the card + * wrapper (config/header/summary/collapsible shell ≈ 1 KB), the trailing + * "more" indicator, and leave headroom for the final markdown text block + * on the last card — 20 KB for step elements keeps every split chunk + * comfortably under the hard ceiling while avoiding chopping on short + * natural-size steps. + */ +export const MAX_STEP_PANEL_BYTES_PER_CARD = 20 * 1024; + /** * Render assistant message content as a Feishu interactive card. * @param messageContent - Array of content blocks (thinking, tool_use, text). @@ -34,10 +54,19 @@ export async function renderMessageCard( { streaming, uploadImage, + totalStepCount, }: { streaming: boolean; // eslint-disable-next-line no-unused-vars uploadImage: (path: string) => Promise; + /** + * Grand total of steps across the whole card chain. When rendering a + * chunk that is only a slice of the real step list (the channel is + * splitting an overflowing message into multiple cards), the chunk's + * own element count understates progress; pass the true total here so + * the "Working on it (N steps)" header stays accurate. + */ + totalStepCount?: number; }, ): Promise { const stepPanel: CollapsiblePanel = { @@ -88,6 +117,7 @@ export async function renderMessageCard( _renderTool(content, stepPanel); } } + const headerStepCount = totalStepCount ?? stepPanel.elements.length; if (!streaming) { // Find the last text block (final response), not all text blocks const lastTextContent = messageContent.findLast((c) => c.type === "text"); @@ -107,10 +137,9 @@ export async function renderMessageCard( } } - const stepCount = stepPanel.elements.length; - if (stepCount > 0) { + if (stepPanel.elements.length > 0) { const stepCountText = - stepCount + " " + (stepCount === 1 ? "step" : "steps"); + headerStepCount + " " + (headerStepCount === 1 ? "step" : "steps"); if (streaming) { stepPanel.header.title.content = `Working on it (${stepCountText})`; card.config!.summary.content = `Working on it (${stepCountText})`; @@ -300,6 +329,106 @@ function _renderTool( } } +/** + * Approximate the JSON byte footprint a step-emitting block will cost + * inside the step panel. Used purely for pre-flight splitting decisions — + * accuracy within ±20% is enough; we just need to avoid chunks that + * would blow past Feishu's 30 KB content cap on long Bash commands or + * dense thinking traces. + * + * The 220-byte base covers the div / icon / plain_text wrapper JSON the + * renderer stamps around each step. + */ +function _estimateStepByteCost(block: AssistantMessage["content"][number]): number { + return JSON.stringify(block).length + 220; +} + +/** + * Split an assistant message's content across multiple cards when it has + * too many step-emitting blocks (thinking + tool_use) — or too many + * bytes' worth — to fit in one Feishu container. Each returned chunk is + * itself a valid input for {@link renderMessageCard}. + * + * Rules: + * - Thinking / tool_use blocks are distributed in their original order + * across chunks. A chunk closes when the next step would push either + * its element count past `maxSteps` or its estimated rendered bytes + * past `maxBytes`. A single oversized step still gets its own chunk + * (we never drop content). + * - Text blocks (final answer / narration) never count toward either + * cap; they're all attached to the LAST chunk so the conversational + * reply lands on the active card, not buried mid-chain. + * - If content fits in a single card by both metrics, returns + * `[content]` unchanged. + * + * Returns at least one chunk — an empty input yields `[[]]`. + */ +export function splitMessageContentForCards( + content: AssistantMessage["content"], + { + maxSteps = MAX_STEPS_PER_CARD, + maxBytes = MAX_STEP_PANEL_BYTES_PER_CARD, + }: { maxSteps?: number; maxBytes?: number } = {}, +): AssistantMessage["content"][] { + const stepBlocks = content.filter( + (c) => c.type === "thinking" || c.type === "tool_use", + ); + const nonStepBlocks = content.filter( + (c) => c.type !== "thinking" && c.type !== "tool_use", + ); + + // Fast path: small enough to fit on a single card. Bytes are estimated + // up-front so we skip walking the list when we're well under budget. + if (stepBlocks.length <= maxSteps) { + let totalBytes = 0; + for (const b of stepBlocks) totalBytes += _estimateStepByteCost(b); + if (totalBytes <= maxBytes) { + return [content]; + } + } + + const chunks: AssistantMessage["content"][] = []; + let current: AssistantMessage["content"] = []; + let currentBytes = 0; + for (const block of stepBlocks) { + const blockBytes = _estimateStepByteCost(block); + const overflow = + current.length >= maxSteps || + (current.length > 0 && currentBytes + blockBytes > maxBytes); + if (overflow) { + chunks.push(current); + current = []; + currentBytes = 0; + } + current.push(block); + currentBytes += blockBytes; + } + if (current.length > 0 || chunks.length === 0) { + chunks.push(current); + } + + // Final answer / text narration belongs on the LAST card — a user + // scrolling the thread expects the reply next to the "live" card, not + // frozen in the middle of the chain. + if (nonStepBlocks.length > 0) { + chunks[chunks.length - 1]!.push( + ...(nonStepBlocks as AssistantMessage["content"]), + ); + } + return chunks; +} + +/** Count step-emitting blocks (thinking + tool_use) across content. */ +export function countStepBlocks(content: AssistantMessage["content"]): number { + let n = 0; + for (const c of content) { + if (c.type === "thinking" || c.type === "tool_use") { + n++; + } + } + return n; +} + /** Create a step element (icon + text) for the collapsible panel. */ function _renderStep(text: string, iconToken: string): DivElement { return { diff --git a/tests/community/feishu/message-renderer.test.ts b/tests/community/feishu/message-renderer.test.ts new file mode 100644 index 0000000..4e85a62 --- /dev/null +++ b/tests/community/feishu/message-renderer.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; + +import { + countStepBlocks, + MAX_STEP_PANEL_BYTES_PER_CARD, + MAX_STEPS_PER_CARD, + splitMessageContentForCards, +} from "@/community/feishu/messaging/message-renderer"; +import type { AssistantMessage } from "@/shared"; + +const thinking = (text: string) => + ({ type: "thinking", thinking: text }) as const; +const toolUse = (name: string, input: Record = {}) => + ({ + type: "tool_use", + id: `t-${name}`, + name, + input, + }) as const; +const textBlock = (text: string) => ({ type: "text" as const, text }); + +describe("splitMessageContentForCards", () => { + test("returns the input unchanged when under both caps", () => { + const content = [ + thinking("a"), + toolUse("Read"), + textBlock("hello"), + ] as AssistantMessage["content"]; + const chunks = splitMessageContentForCards(content); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toBe(content); + }); + + test("splits a 66-step panel into three chunks of 25/25/16 by step count", () => { + const content: AssistantMessage["content"] = []; + for (let i = 0; i < 66; i++) { + content.push(toolUse(`tool_${i}`)); + } + const chunks = splitMessageContentForCards(content); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toHaveLength(25); + expect(chunks[1]).toHaveLength(25); + expect(chunks[2]).toHaveLength(16); + // Order preserved across chunks + expect((chunks[0]![0] as { name: string }).name).toBe("tool_0"); + expect((chunks[1]![0] as { name: string }).name).toBe("tool_25"); + expect((chunks[2]![0] as { name: string }).name).toBe("tool_50"); + }); + + test("splits by byte budget when individual steps are heavy", () => { + // 10 KB description × 4 steps ≈ 40 KB > default 20 KB budget + const fat = "x".repeat(10 * 1024); + const content: AssistantMessage["content"] = []; + for (let i = 0; i < 4; i++) { + content.push(toolUse("Bash", { description: fat })); + } + const chunks = splitMessageContentForCards(content); + // Should produce at least 2 chunks even though we're well under 25 steps + expect(chunks.length).toBeGreaterThan(1); + // Each chunk (except single-oversized-step chunks) should respect the budget + for (const chunk of chunks) { + const chunkJsonBytes = JSON.stringify(chunk).length; + // Either the chunk is one step (unavoidably oversized) or it fits + if (chunk.length > 1) { + expect(chunkJsonBytes).toBeLessThanOrEqual( + MAX_STEP_PANEL_BYTES_PER_CARD + 2 * 1024, + ); + } + } + }); + + test("a single oversized step still lives in its own chunk (never dropped)", () => { + const huge = "y".repeat(30 * 1024); + const content: AssistantMessage["content"] = [ + toolUse("Bash", { description: huge }), + toolUse("Read"), + ]; + const chunks = splitMessageContentForCards(content); + expect(chunks.length).toBeGreaterThanOrEqual(2); + // The huge step should be isolated in its own chunk + const firstChunk = chunks[0]!; + expect(firstChunk).toHaveLength(1); + expect((firstChunk[0] as { name: string }).name).toBe("Bash"); + }); + + test("non-step text blocks always ride on the last chunk", () => { + const content: AssistantMessage["content"] = []; + content.push(textBlock("opening narration")); + for (let i = 0; i < 51; i++) { + content.push(toolUse(`t${i}`)); + } + content.push(textBlock("final answer")); + const chunks = splitMessageContentForCards(content); + expect(chunks).toHaveLength(3); + // First two chunks: pure steps, no text + for (const c of [chunks[0]!, chunks[1]!]) { + for (const block of c) { + expect(block.type).not.toBe("text"); + } + } + // Last chunk carries all text blocks + const lastTexts = chunks[2]!.filter((b) => b.type === "text"); + expect(lastTexts).toHaveLength(2); + expect((lastTexts[0] as { text: string }).text).toBe("opening narration"); + expect((lastTexts[1] as { text: string }).text).toBe("final answer"); + }); + + test("returns [[]] for empty content", () => { + const chunks = splitMessageContentForCards([]); + // Empty content fits in a single (empty) chunk. + expect(chunks).toHaveLength(1); + expect(chunks[0]).toEqual([]); + }); + + test("exposed caps are within Feishu-safe bounds", () => { + expect(MAX_STEPS_PER_CARD).toBeGreaterThan(0); + expect(MAX_STEPS_PER_CARD).toBeLessThanOrEqual(50); + // 30 KB is Feishu's content cap; reserved step-panel budget must leave + // headroom for the card wrapper and the final markdown text. + expect(MAX_STEP_PANEL_BYTES_PER_CARD).toBeLessThan(30 * 1024); + }); +}); + +describe("countStepBlocks", () => { + test("counts thinking + tool_use, ignoring text", () => { + const content = [ + thinking("a"), + toolUse("Bash"), + textBlock("ignore me"), + toolUse("Read"), + ] as AssistantMessage["content"]; + expect(countStepBlocks(content)).toBe(3); + }); + + test("returns 0 for text-only content", () => { + const content = [ + textBlock("one"), + textBlock("two"), + ] as AssistantMessage["content"]; + expect(countStepBlocks(content)).toBe(0); + }); +}); From ea32895564a7305ac1ffbb56ad574fe925784daf Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 18:22:08 +0800 Subject: [PATCH 29/69] fix(feishu): keep operator in whitelist on /allow; reply on reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paper cuts that compounded into "/status silently does nothing": 1. /allow lock-out Running /allow when no whitelist exists initialized an empty Set and then filtered the sender out of the @-targets — the operator lost access to their own bot on the very next message. Pass the sender to addToWhitelist and auto-seed it whenever we materialize a fresh whitelist from the implicit "everyone allowed" state. 2. Silent drops Rejected messages just logged and returned, so to the user it looked like the bot was broken. Reply with a short reason when the dropped message was clearly directed at the bot (slash command, @mention, bot-owned thread, or p2p). Casual group chatter from non-whitelisted members stays silently dropped. --- .../feishu/messaging/message-channel.ts | 68 ++++++++++++++++++- src/kernel/commands/handlers.ts | 2 +- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 9f3ff9e..0759ac3 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -413,14 +413,29 @@ export class FeishuMessageChannel * on the very next inbound message without a restart. Persistence keeps * the change across restarts; we re-use Bun's YAML parser/stringifier so * the file stays round-trippable. + * + * `senderOpenId` is required so we can auto-seed the operator into the + * whitelist when transitioning from the implicit "everyone allowed" state + * (no whitelist configured) to an explicit list. Without this seed, a + * freshly-initialized whitelist would exclude the very user who just ran + * `/allow`, locking them out of their own bot on the next message. */ - async addToWhitelist(openIds: string[]): Promise { + async addToWhitelist( + openIds: string[], + senderOpenId: string, + ): Promise { + const hadWhitelist = !!this._allowedUserOpenIds; if (!this._allowedUserOpenIds) { - // The whitelist was disabled (empty set accepts everyone). Initialize - // a fresh one — the newly-added users become the whole allow-list. this._allowedUserOpenIds = new Set(); } const added: string[] = []; + // Bootstrap guard: the previous state allowed everyone implicitly. + // Preserve access for the operator when we materialize that into an + // explicit list. + if (!hadWhitelist && !this._allowedUserOpenIds.has(senderOpenId)) { + this._allowedUserOpenIds.add(senderOpenId); + added.push(senderOpenId); + } for (const openId of openIds) { if (!this._allowedUserOpenIds.has(openId)) { this._allowedUserOpenIds.add(openId); @@ -1071,6 +1086,37 @@ export class FeishuMessageChannel } } + /** + * Post a short plain-text reply when an inbound message is dropped by a + * gate (whitelist / mention enforcement). Only used for messages whose + * intent is clearly directed at the bot — otherwise the bot would spam + * rejection replies at every unrelated group message. + */ + private async _replyRejection( + messageId: string, + reason: "whitelist" | "mention", + ): Promise { + const text = + reason === "whitelist" + ? "❌ 未授权:你不在本机器人白名单。请让已授权的成员用 `/allow @你` 把你加入。" + : "❌ 本群需要 @ 机器人才能触发对话(`/` 斜杠命令除外)。"; + try { + await this._client.im.message.reply({ + path: { message_id: messageId }, + data: { + msg_type: "text", + content: JSON.stringify({ text }), + reply_in_thread: false, + }, + }); + } catch (err) { + this._logger.warn( + { err, message_id: messageId, reason }, + "failed to send rejection reply", + ); + } + } + private async _replyUpdateFailureMessage(messageId: string): Promise { try { await this._client.im.message.reply({ @@ -1157,11 +1203,25 @@ export class FeishuMessageChannel "inbound message", ); + // A message is "directed at the bot" if the intent is unambiguous — slash + // command, explicit @-mention, inside a thread the bot already owns, or a + // p2p chat. We only surface rejection replies for these; casual group + // chatter from non-whitelisted members gets silently dropped to avoid + // spamming the chat. + const isIntendedForBot = + isSlashCommand || + isBotMentioned || + isInBotThread || + chatType === "p2p"; + if (!isAllowedSender) { this._logger.info( { message_id: messageId, sender_open_id: senderOpenId }, "dropping inbound: sender not in whitelist", ); + if (isIntendedForBot) { + await this._replyRejection(messageId, "whitelist"); + } return; } if (!mentionOk) { @@ -1169,6 +1229,8 @@ export class FeishuMessageChannel { message_id: messageId, chat_id: chatId }, "dropping inbound: bot not @mentioned in group chat", ); + // `mentionEnforced` already excludes slash_command / in_bot_thread, so + // reaching here means the message has no directed signal. Stay silent. return; } diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 538f395..7bc1dea 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -433,7 +433,7 @@ const allowHandler: CommandHandler = { } let added: string[]; try { - added = await channel.addToWhitelist(targets); + added = await channel.addToWhitelist(targets, senderOpenId); } catch (err) { ctx.logger.error({ err, targets }, "addToWhitelist failed"); return `❌ 写入白名单失败:${(err as Error).message}`; From 97be1766ed94021a72ad5fe46221d029398f4a42 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 21:03:37 +0800 Subject: [PATCH 30/69] refactor(feishu): truncate step panel; spill only final markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the earlier card-chain approach (519f7fc) in favor of what the user actually wanted: tool steps never cause new cards, only the final model output spills when it's too large. Step panel (one card, always): - Clip per-step display text to 200 chars / first line — keeps long Bash descriptions and multi-KB thinking traces from bloating the card JSON on their own. - Drop oldest rows when step count exceeds 25. Pin a single `… N earlier steps` summary row at the top so the user sees both the most recent activity and how much history was hidden. Header still shows the TRUE step count. Final markdown (may spill): - Split into byte-bounded chunks (20 KB each) at paragraph / line boundaries. First chunk rides on the primary card; rest become text-only reply cards. - Combined with the pre-existing 5-tables-per-card split. Fix a latent bug uncovered while sizing the markdown budget: the non-streaming renderer wrote the full markdown into both `config.summary.content` AND `body.elements`, doubling the card's byte cost. Summary now carries a short preview (first line, up to 180 chars) instead — full content stays in the body. --- .../feishu/messaging/message-channel.ts | 288 +++++++----------- .../feishu/messaging/message-renderer.ts | 282 ++++++++++------- .../community/feishu/message-renderer.test.ts | 219 +++++++------ 3 files changed, 393 insertions(+), 396 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 0759ac3..5bc3b72 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -22,32 +22,11 @@ import { import { feishuBotGroups, feishuThreads } from "./data"; import { renderMessageCard, - splitMarkdownByTables, - splitMessageContentForCards, + splitMarkdownForCards, } from "./message-renderer"; import type { Card, MessageReceiveEventData } from "./types"; import { convertPostToMarkdown } from "./utils"; -/** - * A chain of Feishu cards that together render a single logical assistant - * message. The channel splits overflow-prone content (many tool steps, - * table-heavy markdown) into multiple cards pre-flight rather than - * retrying after Feishu rejects the PATCH. - * - * Invariants: - * - `cards[0]` is the anchor message_id, exposed to callers as - * `AssistantMessage.id`. - * - `cards[0..finalized-1]` are frozen: fully rendered in non-streaming - * form; we never PATCH them again. - * - `cards[cards.length - 1]` (when `finalized < cards.length`) is the - * active card receiving live updates. - */ -interface CardChain { - cards: string[]; - finalized: number; - replyInThread: boolean; -} - function _isFeishuBadRequestError(err: unknown): boolean { if (!err || typeof err !== "object") { return false; @@ -83,7 +62,12 @@ export class FeishuMessageChannel private _client: Client; private _db: DrizzleDB; private _failedCardUpdateMessages = new Set(); - private _cardChains = new Map(); + /** + * Primary message ids for which we've already posted the final + * markdown-continuation cards. Kept so that a repeat `streaming: false` + * update doesn't double-post continuations. + */ + private _finalizedPrimaries = new Set(); private _logger: Logger; private _requireMention: boolean; private _botOpenId?: string; @@ -598,14 +582,15 @@ export class FeishuMessageChannel replyInThread = true, }: { streaming?: boolean; replyInThread?: boolean } = {}, ): Promise { - const chunks = this._splitIntoCardChunks(message.content, streaming); + const { primaryContent, markdownContinuations } = + this._prepareCardPayload(message.content, streaming); if (!streaming) { this._logOutboundMessage(message.session_id, message.content); } - // Post the primary (first chunk) as a reply to the user's message. - const primaryCard = await this._renderChunk(chunks[0]!, { - streaming: streaming && chunks.length === 1, + const primaryCard = await renderMessageCard(primaryContent, { + streaming, + uploadImage: this.uploadImage.bind(this), }); const { data: replyMessage } = await this._client.im.message.reply({ path: { message_id: messageId }, @@ -618,25 +603,23 @@ export class FeishuMessageChannel if (!replyMessage?.message_id) { throw new Error("Failed to reply message"); } - const anchorId = replyMessage.message_id; + const primaryId = replyMessage.message_id; if (replyInThread && replyMessage.thread_id) { this._mapThreadToSession(replyMessage.thread_id, message.session_id); } - const chain: CardChain = { - cards: [anchorId], - finalized: 0, - replyInThread, - }; - await this._growChainToFit(chain, chunks, streaming); - if (!streaming) { - chain.finalized = chain.cards.length; + if (!streaming && markdownContinuations.length > 0) { + await this._postMarkdownContinuations( + primaryId, + markdownContinuations, + replyInThread, + ); + this._finalizedPrimaries.add(primaryId); } - this._cardChains.set(anchorId, chain); const assistantMessage = message as AssistantMessage; - assistantMessage.id = anchorId; + assistantMessage.id = primaryId; if (!streaming) { await this._sendFileAttachmentsForFinalText( @@ -650,11 +633,13 @@ export class FeishuMessageChannel async postMessage( message: Omit, ): Promise { - const chunks = this._splitIntoCardChunks(message.content, false); + const { primaryContent, markdownContinuations } = + this._prepareCardPayload(message.content, false); this._logOutboundMessage(message.session_id, message.content); - const primaryCard = await this._renderChunk(chunks[0]!, { + const primaryCard = await renderMessageCard(primaryContent, { streaming: false, + uploadImage: this.uploadImage.bind(this), }); const { data } = await this._client.im.message.create({ params: { receive_id_type: "chat_id" }, @@ -667,23 +652,19 @@ export class FeishuMessageChannel if (!data?.message_id) { throw new Error("Failed to post message"); } - const anchorId = data.message_id; - - const chain: CardChain = { - cards: [anchorId], - finalized: 0, - // post-then-reply continuation cards attach to the primary with - // reply_in_thread:true, matching the pre-chain behavior. - replyInThread: true, - }; - await this._growChainToFit(chain, chunks, /* streaming */ false); - // postMessage is always a final one-shot — lock the whole chain so the - // primary (created outside _growChainToFit) is counted as finalized too. - chain.finalized = chain.cards.length; - this._cardChains.set(anchorId, chain); + const primaryId = data.message_id; + + if (markdownContinuations.length > 0) { + await this._postMarkdownContinuations( + primaryId, + markdownContinuations, + /* replyInThread */ true, + ); + } + this._finalizedPrimaries.add(primaryId); const assistantMessage = message as AssistantMessage; - assistantMessage.id = anchorId; + assistantMessage.id = primaryId; await this._sendFileAttachmentsForFinalText( assistantMessage.id, @@ -724,58 +705,21 @@ export class FeishuMessageChannel return; } - const chain = - this._cardChains.get(message.id) ?? - // Lazy-init: a channel restart can lose in-memory chain state. Treat - // the incoming `message.id` as the anchor of a fresh single-card chain - // and let `_growChainToFit` append continuations as needed. - ({ - cards: [message.id], - finalized: 0, - replyInThread: true, - } as CardChain); - - const chunks = this._splitIntoCardChunks(message.content, streaming); + const { primaryContent, markdownContinuations } = + this._prepareCardPayload(message.content, streaming); if (!streaming) { this._logOutboundMessage(message.session_id, message.content); } + const card = await renderMessageCard(primaryContent, { + streaming, + uploadImage: this.uploadImage.bind(this), + }); try { - // Freeze any previously-active cards that are no longer the last in - // the chain (new step cards have been added after them). - while (chain.finalized < Math.min(chunks.length - 1, chain.cards.length)) { - const idx = chain.finalized; - const frozenCard = await this._renderChunk(chunks[idx]!, { - streaming: false, - }); - await this._patchCard(chain.cards[idx]!, frozenCard); - chain.finalized++; - } - - const initialLen = chain.cards.length; - await this._growChainToFit(chain, chunks, streaming); - - // If we didn't grow the chain this round, the active (last) card - // still needs a refresh to pick up the new steps. Skip when the last - // card is already frozen, or when the chunk it was rendered from no - // longer exists (defensive — content isn't expected to shrink). - const lastIdx = chain.cards.length - 1; - if ( - chain.cards.length === initialLen && - lastIdx >= 0 && - chain.finalized <= lastIdx && - lastIdx < chunks.length - ) { - const card = await this._renderChunk(chunks[lastIdx]!, { streaming }); - await this._patchCard(chain.cards[lastIdx]!, card); - } - // Final state reached — lock the whole chain so any stray later calls - // are no-ops rather than redundant PATCHes. - if (!streaming) { - chain.finalized = chain.cards.length; - } - - this._cardChains.set(message.id, chain); + await this._client.im.message.patch({ + path: { message_id: message.id }, + data: { content: JSON.stringify(card) }, + }); } catch (err) { if (_isFeishuBadRequestError(err)) { this._failedCardUpdateMessages.add(message.id); @@ -789,7 +733,22 @@ export class FeishuMessageChannel throw err; } + // Markdown continuations are only meaningful once the run is final — + // during streaming the "final text" hasn't stabilized yet. Post them + // exactly once per primary message. + if ( + !streaming && + markdownContinuations.length > 0 && + !this._finalizedPrimaries.has(message.id) + ) { + await this._postMarkdownContinuations( + message.id, + markdownContinuations, + /* replyInThread */ true, + ); + } if (!streaming) { + this._finalizedPrimaries.add(message.id); await this._sendFileAttachmentsForFinalText(message.id, message.content); } } @@ -927,103 +886,70 @@ export class FeishuMessageChannel } /** - * Split assistant message content into a list of card-ready chunks. + * Split an assistant message's final text (if any) into the part that + * fits on the primary card plus a list of overflow markdown strings + * that will each become a text-only continuation card. * - * Stage 1 — step panel overflow: spread thinking/tool_use blocks across - * chunks capped at {@link MAX_STEPS_PER_CARD} so no single panel exceeds - * Feishu's 50-element container cap. - * - * Stage 2 — markdown table overflow (non-streaming only): the final - * answer lives on the last step chunk; if it carries more than Feishu's - * 5-tables-per-card limit, the surplus gets peeled off into text-only - * continuation chunks appended after the last step chunk. - * - * During streaming we skip stage 2 because the final text isn't present - * yet (and any interim text is ephemeral). + * Tool steps never spill — the renderer truncates the step panel to a + * windowed view when there are too many. Only the final answer gets + * spilled, and only when it's either table-dense (Feishu caps tables + * per card) or byte-heavy (Feishu caps card body size). During + * streaming we skip the spill entirely: the "final text" hasn't + * stabilized and any interim text is ephemeral. */ - private _splitIntoCardChunks( + private _prepareCardPayload( content: AssistantMessage["content"], streaming: boolean, - ): AssistantMessage["content"][] { - const chunks = splitMessageContentForCards(content); + ): { + primaryContent: AssistantMessage["content"]; + markdownContinuations: string[]; + } { if (streaming) { - return chunks; + return { primaryContent: content, markdownContinuations: [] }; } - const last = chunks[chunks.length - 1]!; - const lastTextIdx = last.findLastIndex((c) => c.type === "text"); - if (lastTextIdx === -1) return chunks; - const lastText = last[lastTextIdx]!; - if (lastText.type !== "text") return chunks; - const textChunks = splitMarkdownByTables(lastText.text); - if (textChunks.length <= 1) return chunks; - const rewrittenLast = [...last]; - rewrittenLast[lastTextIdx] = { ...lastText, text: textChunks[0]! }; - chunks[chunks.length - 1] = rewrittenLast as AssistantMessage["content"]; - for (let i = 1; i < textChunks.length; i++) { - chunks.push([ - { type: "text", text: textChunks[i]! }, - ] as AssistantMessage["content"]); + const lastTextIdx = content.findLastIndex((c) => c.type === "text"); + if (lastTextIdx === -1) { + return { primaryContent: content, markdownContinuations: [] }; } - return chunks; - } - - /** Render a single card-chunk. */ - private async _renderChunk( - chunk: AssistantMessage["content"], - { streaming }: { streaming: boolean }, - ): Promise { - return renderMessageCard(chunk, { - streaming, - uploadImage: this.uploadImage.bind(this), - }); - } - - /** PATCH an existing Feishu card with a new body. */ - private async _patchCard(messageId: string, card: Card): Promise { - await this._client.im.message.patch({ - path: { message_id: messageId }, - data: { content: JSON.stringify(card) }, - }); + const lastText = content[lastTextIdx]!; + if (lastText.type !== "text") { + return { primaryContent: content, markdownContinuations: [] }; + } + const markdownChunks = splitMarkdownForCards(lastText.text); + if (markdownChunks.length <= 1) { + return { primaryContent: content, markdownContinuations: [] }; + } + const rewritten = [...content]; + rewritten[lastTextIdx] = { ...lastText, text: markdownChunks[0]! }; + return { + primaryContent: rewritten as AssistantMessage["content"], + markdownContinuations: markdownChunks.slice(1), + }; } /** - * Extend {@link chain} so it has a Feishu card for every chunk in - * {@link chunks}. Newly created non-last chunks are posted in frozen - * form and immediately marked finalized — we won't touch them again. - * The trailing chunk is posted in its current streaming state so it - * keeps receiving live updates. + * Post the overflow markdown chunks as text-only reply cards + * underneath the primary. Called at most once per primary, at the + * moment we finalize the message. */ - private async _growChainToFit( - chain: CardChain, - chunks: AssistantMessage["content"][], - streaming: boolean, + private async _postMarkdownContinuations( + primaryId: string, + markdownChunks: string[], + replyInThread: boolean, ): Promise { - while (chain.cards.length < chunks.length) { - const idx = chain.cards.length; - const isLast = idx === chunks.length - 1; - const card = await this._renderChunk(chunks[idx]!, { - streaming: streaming && isLast, + for (const text of markdownChunks) { + const card = await renderMessageCard([{ type: "text", text }], { + streaming: false, + uploadImage: this.uploadImage.bind(this), }); - // Continuation cards hang off the anchor so Feishu renders them as - // siblings within the same topic thread. Replying to the previous - // continuation would nest them infinitely. - const { data } = await this._client.im.message.reply({ - path: { message_id: chain.cards[0]! }, + await this._client.im.message.reply({ + path: { message_id: primaryId }, data: { msg_type: "interactive", content: JSON.stringify(card), - reply_in_thread: chain.replyInThread, + reply_in_thread: replyInThread, }, }); - if (!data?.message_id) { - throw new Error("Failed to post continuation card"); - } - chain.cards.push(data.message_id); - // Finalize non-last cards unconditionally, and the trailing card too - // when the whole message is non-streaming (no more updates coming). - if (!isLast || !streaming) { - chain.finalized++; - } } } diff --git a/src/community/feishu/messaging/message-renderer.ts b/src/community/feishu/messaging/message-renderer.ts index 12014c0..e471ec5 100644 --- a/src/community/feishu/messaging/message-renderer.ts +++ b/src/community/feishu/messaging/message-renderer.ts @@ -24,24 +24,48 @@ import type { } from "./types"; /** - * Maximum step-panel elements per card. A single Feishu container is capped - * at 50 elements server-side (error 11310 `element exceeds the limit`); we - * keep cards well under that for UX breathing room and let the channel - * spill overflow into a follow-up card instead of truncating. + * Maximum step-panel elements per card. A single Feishu container is + * capped at 50 server-side (error 11310 `element exceeds the limit`); we + * keep cards well under that so long runs don't get rejected. The panel + * shows the most recent N-1 steps plus a single "… K earlier steps" row + * pinned at the top when the true count exceeds this cap. */ export const MAX_STEPS_PER_CARD = 25; /** - * Per-card step-panel byte budget used by the content splitter. + * Per-step text clip length. Agent output frequently contains huge Bash + * descriptions or multi-KB thinking traces; displayed as-is they'd push + * the card past Feishu's 30 KB body cap on their own. Each step's text + * is truncated to at most this many characters (first line only) so the + * panel stays legible — the full payload is always preserved in the + * session jsonl for post-hoc inspection. + */ +export const MAX_STEP_TEXT_CHARS = 200; + +/** + * Byte budget for a single card's markdown text block. Above this we + * spill the overflow into follow-up text-only cards. + * + * Feishu's content ceiling is 30 KB. Budget breakdown on a fully + * populated card: + * - card wrapper (config, body shell, headers): ~800 bytes + * - step panel with 25 rows, per-step text clipped to 200 chars, + * Chinese-filled worst case: ~8–10 KB (ASCII-heavy: ~5 KB) + * - markdown block (in body.elements AND a short summary preview): + * headroom → up to ~20 KB here * - * Feishu caps a card's JSON `content` field at ~30 KB. Subtract the card - * wrapper (config/header/summary/collapsible shell ≈ 1 KB), the trailing - * "more" indicator, and leave headroom for the final markdown text block - * on the last card — 20 KB for step elements keeps every split chunk - * comfortably under the hard ceiling while avoiding chopping on short - * natural-size steps. + * The summary preview is intentionally short (see `_summarizeMarkdown` + * below) so we're not double-paying the markdown bytes. + */ +export const MAX_MARKDOWN_BYTES_PER_CHUNK = 20 * 1024; + +/** + * Max summary length (characters) shown in the Feishu notification / + * chat-list preview. Kept tiny — the full message lives in the body + * element; duplicating the whole markdown here would double the card's + * byte cost for no visible gain. */ -export const MAX_STEP_PANEL_BYTES_PER_CARD = 20 * 1024; +const _SUMMARY_PREVIEW_CHARS = 180; /** * Render assistant message content as a Feishu interactive card. @@ -54,19 +78,10 @@ export async function renderMessageCard( { streaming, uploadImage, - totalStepCount, }: { streaming: boolean; // eslint-disable-next-line no-unused-vars uploadImage: (path: string) => Promise; - /** - * Grand total of steps across the whole card chain. When rendering a - * chunk that is only a slice of the real step list (the channel is - * splitting an overflowing message into multiple cards), the chunk's - * own element count understates progress; pass the true total here so - * the "Working on it (N steps)" header stays accurate. - */ - totalStepCount?: number; }, ): Promise { const stepPanel: CollapsiblePanel = { @@ -117,7 +132,11 @@ export async function renderMessageCard( _renderTool(content, stepPanel); } } - const headerStepCount = totalStepCount ?? stepPanel.elements.length; + // Capture the real step count *before* we drop oldest rows to fit the + // card — the header wants to show overall progress, not the windowed + // view size. + const trueStepCount = stepPanel.elements.length; + _truncateStepPanel(stepPanel); if (!streaming) { // Find the last text block (final response), not all text blocks const lastTextContent = messageContent.findLast((c) => c.type === "text"); @@ -132,14 +151,14 @@ export async function renderMessageCard( tag: "markdown", content: markdownContent, }; - card.config!.summary.content = markdownContent; + card.config!.summary.content = _summarizeMarkdown(markdownContent); card.body.elements.push(resultElement); } } - if (stepPanel.elements.length > 0) { + if (trueStepCount > 0) { const stepCountText = - headerStepCount + " " + (headerStepCount === 1 ? "step" : "steps"); + trueStepCount + " " + (trueStepCount === 1 ? "step" : "steps"); if (streaming) { stepPanel.header.title.content = `Working on it (${stepCountText})`; card.config!.summary.content = `Working on it (${stepCountText})`; @@ -330,103 +349,58 @@ function _renderTool( } /** - * Approximate the JSON byte footprint a step-emitting block will cost - * inside the step panel. Used purely for pre-flight splitting decisions — - * accuracy within ±20% is enough; we just need to avoid chunks that - * would blow past Feishu's 30 KB content cap on long Bash commands or - * dense thinking traces. - * - * The 220-byte base covers the div / icon / plain_text wrapper JSON the - * renderer stamps around each step. + * Drop oldest rows from the step panel when the true step count exceeds + * Feishu's safe container size. Keeps the most recent + * `MAX_STEPS_PER_CARD - 1` rows and prepends a single summary row like + * `… 42 earlier steps` so the user sees both what just happened and + * how much came before it. */ -function _estimateStepByteCost(block: AssistantMessage["content"][number]): number { - return JSON.stringify(block).length + 220; +function _truncateStepPanel(stepPanel: CollapsiblePanel): void { + const total = stepPanel.elements.length; + if (total <= MAX_STEPS_PER_CARD) return; + const kept = MAX_STEPS_PER_CARD - 1; + const dropped = total - kept; + const tail = stepPanel.elements.slice(-kept); + stepPanel.elements = [ + _renderStep(`… ${dropped} earlier steps`, "more_outlined"), + ...tail, + ]; } /** - * Split an assistant message's content across multiple cards when it has - * too many step-emitting blocks (thinking + tool_use) — or too many - * bytes' worth — to fit in one Feishu container. Each returned chunk is - * itself a valid input for {@link renderMessageCard}. - * - * Rules: - * - Thinking / tool_use blocks are distributed in their original order - * across chunks. A chunk closes when the next step would push either - * its element count past `maxSteps` or its estimated rendered bytes - * past `maxBytes`. A single oversized step still gets its own chunk - * (we never drop content). - * - Text blocks (final answer / narration) never count toward either - * cap; they're all attached to the LAST chunk so the conversational - * reply lands on the active card, not buried mid-chain. - * - If content fits in a single card by both metrics, returns - * `[content]` unchanged. - * - * Returns at least one chunk — an empty input yields `[[]]`. + * Build a short preview of the final markdown for `config.summary.content` + * (Feishu notifications / chat list snippet). Keep it tight — the full + * markdown is already in body.elements, so duplicating it here just + * doubles the card's byte cost for no visible benefit. */ -export function splitMessageContentForCards( - content: AssistantMessage["content"], - { - maxSteps = MAX_STEPS_PER_CARD, - maxBytes = MAX_STEP_PANEL_BYTES_PER_CARD, - }: { maxSteps?: number; maxBytes?: number } = {}, -): AssistantMessage["content"][] { - const stepBlocks = content.filter( - (c) => c.type === "thinking" || c.type === "tool_use", - ); - const nonStepBlocks = content.filter( - (c) => c.type !== "thinking" && c.type !== "tool_use", - ); - - // Fast path: small enough to fit on a single card. Bytes are estimated - // up-front so we skip walking the list when we're well under budget. - if (stepBlocks.length <= maxSteps) { - let totalBytes = 0; - for (const b of stepBlocks) totalBytes += _estimateStepByteCost(b); - if (totalBytes <= maxBytes) { - return [content]; - } +function _summarizeMarkdown(markdown: string): string { + const trimmed = markdown.trim(); + if (trimmed.length === 0) return ""; + const firstLineEnd = trimmed.indexOf("\n"); + const firstLine = firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd); + if (firstLine.length <= _SUMMARY_PREVIEW_CHARS) { + return firstLineEnd === -1 ? firstLine : firstLine + " …"; } - - const chunks: AssistantMessage["content"][] = []; - let current: AssistantMessage["content"] = []; - let currentBytes = 0; - for (const block of stepBlocks) { - const blockBytes = _estimateStepByteCost(block); - const overflow = - current.length >= maxSteps || - (current.length > 0 && currentBytes + blockBytes > maxBytes); - if (overflow) { - chunks.push(current); - current = []; - currentBytes = 0; - } - current.push(block); - currentBytes += blockBytes; - } - if (current.length > 0 || chunks.length === 0) { - chunks.push(current); - } - - // Final answer / text narration belongs on the LAST card — a user - // scrolling the thread expects the reply next to the "live" card, not - // frozen in the middle of the chain. - if (nonStepBlocks.length > 0) { - chunks[chunks.length - 1]!.push( - ...(nonStepBlocks as AssistantMessage["content"]), - ); - } - return chunks; + return firstLine.slice(0, _SUMMARY_PREVIEW_CHARS - 1) + "…"; } -/** Count step-emitting blocks (thinking + tool_use) across content. */ -export function countStepBlocks(content: AssistantMessage["content"]): number { - let n = 0; - for (const c of content) { - if (c.type === "thinking" || c.type === "tool_use") { - n++; - } +/** + * Clip a step's display text so the panel stays legible even when the + * underlying tool use carries a huge Bash command or a multi-KB thinking + * trace. Takes the first line (up to `maxChars`) and appends an ellipsis + * when anything was dropped. + */ +function _clipStepText( + text: string, + maxChars: number = MAX_STEP_TEXT_CHARS, +): string { + const firstLineEnd = text.indexOf("\n"); + const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd); + const hadMoreLines = firstLineEnd !== -1; + if (firstLine.length <= maxChars) { + return hadMoreLines ? firstLine + " …" : firstLine; } - return n; + return firstLine.slice(0, maxChars - 1) + "…"; } /** Create a step element (icon + text) for the collapsible panel. */ @@ -442,7 +416,7 @@ function _renderStep(text: string, iconToken: string): DivElement { tag: "plain_text", text_color: "grey", text_size: "notation", - content: text, + content: _clipStepText(text), }, }; } @@ -512,3 +486,83 @@ export function splitMarkdownByTables( return chunks; } + +/** + * Split markdown into byte-bounded chunks so a single final answer + * doesn't overflow Feishu's 30 KB card body. Splits are preferred at + * paragraph boundaries (blank lines) to keep chunks readable; if a + * single paragraph is itself larger than `maxBytes`, falls back to line + * boundaries, then to a hard character slice as last resort. + * + * The returned chunks together reproduce the input verbatim, with + * inter-chunk whitespace trimmed at the split points. + */ +export function splitMarkdownByBytes( + markdown: string, + maxBytes: number = MAX_MARKDOWN_BYTES_PER_CHUNK, +): string[] { + if (Buffer.byteLength(markdown, "utf8") <= maxBytes) { + return [markdown]; + } + const chunks: string[] = []; + let current = ""; + const flush = () => { + if (current.trim().length > 0) chunks.push(current.trim()); + current = ""; + }; + const appendWithSeparator = (piece: string, sep: string) => { + const combined = current ? current + sep + piece : piece; + if (Buffer.byteLength(combined, "utf8") <= maxBytes) { + current = combined; + } else { + flush(); + if (Buffer.byteLength(piece, "utf8") <= maxBytes) { + current = piece; + } else { + // Piece itself is oversized — emit as-is so no content is lost. + // Callers should expect this chunk to be over budget; downstream + // Feishu may still reject it, but that's a degenerate case worth + // surfacing as-is rather than silently dropping. + chunks.push(piece); + current = ""; + } + } + }; + + const paragraphs = markdown.split(/\n\s*\n/); + for (const para of paragraphs) { + if (Buffer.byteLength(para, "utf8") <= maxBytes) { + appendWithSeparator(para, "\n\n"); + continue; + } + // Paragraph itself too big — fall back to splitting by line. + flush(); + const lines = para.split("\n"); + for (const line of lines) { + appendWithSeparator(line, "\n"); + } + } + flush(); + return chunks; +} + +/** + * Compose markdown splitters: first enforce the table-per-card cap + * (Feishu renders at most 5 table components), then enforce the byte + * cap per chunk. Result is a flat list of markdown strings each + * individually safe to put on a single Feishu card. + */ +export function splitMarkdownForCards( + markdown: string, + { + maxTables = 5, + maxBytes = MAX_MARKDOWN_BYTES_PER_CHUNK, + }: { maxTables?: number; maxBytes?: number } = {}, +): string[] { + const tableChunks = splitMarkdownByTables(markdown, maxTables); + const out: string[] = []; + for (const chunk of tableChunks) { + out.push(...splitMarkdownByBytes(chunk, maxBytes)); + } + return out; +} diff --git a/tests/community/feishu/message-renderer.test.ts b/tests/community/feishu/message-renderer.test.ts index 4e85a62..7b50ea1 100644 --- a/tests/community/feishu/message-renderer.test.ts +++ b/tests/community/feishu/message-renderer.test.ts @@ -1,13 +1,20 @@ import { describe, expect, test } from "bun:test"; import { - countStepBlocks, - MAX_STEP_PANEL_BYTES_PER_CARD, + MAX_MARKDOWN_BYTES_PER_CHUNK, + MAX_STEP_TEXT_CHARS, MAX_STEPS_PER_CARD, - splitMessageContentForCards, + renderMessageCard, + splitMarkdownByBytes, + splitMarkdownForCards, } from "@/community/feishu/messaging/message-renderer"; import type { AssistantMessage } from "@/shared"; +type StepElement = { + tag: "div"; + text: { content: string }; +}; + const thinking = (text: string) => ({ type: "thinking", thinking: text }) as const; const toolUse = (name: string, input: Record = {}) => @@ -17,126 +24,136 @@ const toolUse = (name: string, input: Record = {}) => name, input, }) as const; -const textBlock = (text: string) => ({ type: "text" as const, text }); +const noopUpload = async (p: string) => p; -describe("splitMessageContentForCards", () => { - test("returns the input unchanged when under both caps", () => { +describe("renderMessageCard step panel", () => { + test("renders one step per thinking/tool_use block up to the cap", async () => { const content = [ - thinking("a"), - toolUse("Read"), - textBlock("hello"), + thinking("hello"), + toolUse("Read", { file_path: "/tmp/x" }), ] as AssistantMessage["content"]; - const chunks = splitMessageContentForCards(content); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toBe(content); + const card = await renderMessageCard(content, { + streaming: true, + uploadImage: noopUpload, + }); + const panel = card.body.elements[0] as { elements: StepElement[] }; + expect(panel.elements).toHaveLength(2); + expect(panel.elements[0]!.text.content).toBe("hello"); }); - test("splits a 66-step panel into three chunks of 25/25/16 by step count", () => { + test("keeps only the last N-1 rows + an ellipsis summary on overflow", async () => { const content: AssistantMessage["content"] = []; for (let i = 0; i < 66; i++) { - content.push(toolUse(`tool_${i}`)); + content.push(toolUse("Bash", { description: `step ${i}` })); } - const chunks = splitMessageContentForCards(content); - expect(chunks).toHaveLength(3); - expect(chunks[0]).toHaveLength(25); - expect(chunks[1]).toHaveLength(25); - expect(chunks[2]).toHaveLength(16); - // Order preserved across chunks - expect((chunks[0]![0] as { name: string }).name).toBe("tool_0"); - expect((chunks[1]![0] as { name: string }).name).toBe("tool_25"); - expect((chunks[2]![0] as { name: string }).name).toBe("tool_50"); + const card = await renderMessageCard(content, { + streaming: true, + uploadImage: noopUpload, + }); + const panel = card.body.elements[0] as { + elements: StepElement[]; + header: { title: { content: string } }; + }; + expect(panel.elements).toHaveLength(MAX_STEPS_PER_CARD); + // Top row is the ellipsis summary + expect(panel.elements[0]!.text.content).toMatch(/^… \d+ earlier steps$/); + // Header shows the TRUE count, not the windowed view size + expect(panel.header.title.content).toBe("Working on it (66 steps)"); + // Tail preserves the most recent steps + expect(panel.elements[panel.elements.length - 1]!.text.content).toBe( + "step 65", + ); }); - test("splits by byte budget when individual steps are heavy", () => { - // 10 KB description × 4 steps ≈ 40 KB > default 20 KB budget - const fat = "x".repeat(10 * 1024); - const content: AssistantMessage["content"] = []; - for (let i = 0; i < 4; i++) { - content.push(toolUse("Bash", { description: fat })); - } - const chunks = splitMessageContentForCards(content); - // Should produce at least 2 chunks even though we're well under 25 steps - expect(chunks.length).toBeGreaterThan(1); - // Each chunk (except single-oversized-step chunks) should respect the budget - for (const chunk of chunks) { - const chunkJsonBytes = JSON.stringify(chunk).length; - // Either the chunk is one step (unavoidably oversized) or it fits - if (chunk.length > 1) { - expect(chunkJsonBytes).toBeLessThanOrEqual( - MAX_STEP_PANEL_BYTES_PER_CARD + 2 * 1024, - ); - } - } + test("per-step text is clipped to the single-line char cap", async () => { + const hugeDesc = "x".repeat(MAX_STEP_TEXT_CHARS + 500); + const content = [ + toolUse("Bash", { description: hugeDesc }), + ] as AssistantMessage["content"]; + const card = await renderMessageCard(content, { + streaming: true, + uploadImage: noopUpload, + }); + const panel = card.body.elements[0] as { elements: StepElement[] }; + expect(panel.elements[0]!.text.content.length).toBeLessThanOrEqual( + MAX_STEP_TEXT_CHARS, + ); + expect(panel.elements[0]!.text.content.endsWith("…")).toBe(true); }); - test("a single oversized step still lives in its own chunk (never dropped)", () => { - const huge = "y".repeat(30 * 1024); - const content: AssistantMessage["content"] = [ - toolUse("Bash", { description: huge }), - toolUse("Read"), - ]; - const chunks = splitMessageContentForCards(content); - expect(chunks.length).toBeGreaterThanOrEqual(2); - // The huge step should be isolated in its own chunk - const firstChunk = chunks[0]!; - expect(firstChunk).toHaveLength(1); - expect((firstChunk[0] as { name: string }).name).toBe("Bash"); + test("multi-line text is clipped to the first line with ellipsis hint", async () => { + const multiline = "first line\nsecond line\nthird line"; + const content = [thinking(multiline)] as AssistantMessage["content"]; + const card = await renderMessageCard(content, { + streaming: true, + uploadImage: noopUpload, + }); + const panel = card.body.elements[0] as { elements: StepElement[] }; + expect(panel.elements[0]!.text.content).toBe("first line …"); }); +}); - test("non-step text blocks always ride on the last chunk", () => { - const content: AssistantMessage["content"] = []; - content.push(textBlock("opening narration")); - for (let i = 0; i < 51; i++) { - content.push(toolUse(`t${i}`)); - } - content.push(textBlock("final answer")); - const chunks = splitMessageContentForCards(content); - expect(chunks).toHaveLength(3); - // First two chunks: pure steps, no text - for (const c of [chunks[0]!, chunks[1]!]) { - for (const block of c) { - expect(block.type).not.toBe("text"); - } - } - // Last chunk carries all text blocks - const lastTexts = chunks[2]!.filter((b) => b.type === "text"); - expect(lastTexts).toHaveLength(2); - expect((lastTexts[0] as { text: string }).text).toBe("opening narration"); - expect((lastTexts[1] as { text: string }).text).toBe("final answer"); +describe("renderMessageCard final text", () => { + test("summary preview stays short when the final markdown is huge", async () => { + const bigMarkdown = "# Title\n\n" + "x".repeat(10_000); + const content = [ + { type: "text", text: bigMarkdown }, + ] as AssistantMessage["content"]; + const card = await renderMessageCard(content, { + streaming: false, + uploadImage: noopUpload, + }); + const summary = (card.config as { summary: { content: string } }).summary + .content; + // Body still carries the full markdown + const body = card.body.elements.find( + (e) => (e as { tag: string }).tag === "markdown", + ) as { content: string } | undefined; + expect(body?.content.length).toBeGreaterThan(5000); + // Summary is a preview — nowhere near the full markdown length + expect(summary.length).toBeLessThan(500); + }); +}); + +describe("splitMarkdownByBytes", () => { + test("returns a single chunk when already under budget", () => { + const chunks = splitMarkdownByBytes("hello world", 1024); + expect(chunks).toEqual(["hello world"]); }); - test("returns [[]] for empty content", () => { - const chunks = splitMessageContentForCards([]); - // Empty content fits in a single (empty) chunk. - expect(chunks).toHaveLength(1); - expect(chunks[0]).toEqual([]); + test("splits at paragraph boundaries when over budget", () => { + const paragraph = "x".repeat(600); + const markdown = [paragraph, paragraph, paragraph].join("\n\n"); + const chunks = splitMarkdownByBytes(markdown, 800); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(1600); // well under 2x budget + } + // Content is preserved: concatenating chunks recovers the paragraphs + const joined = chunks.join("\n\n"); + expect(joined).toContain(paragraph); }); - test("exposed caps are within Feishu-safe bounds", () => { - expect(MAX_STEPS_PER_CARD).toBeGreaterThan(0); - expect(MAX_STEPS_PER_CARD).toBeLessThanOrEqual(50); - // 30 KB is Feishu's content cap; reserved step-panel budget must leave - // headroom for the card wrapper and the final markdown text. - expect(MAX_STEP_PANEL_BYTES_PER_CARD).toBeLessThan(30 * 1024); + test("falls back to line splitting when a single paragraph is oversized", () => { + const lines: string[] = []; + for (let i = 0; i < 20; i++) lines.push("x".repeat(500)); + const markdown = lines.join("\n"); // one paragraph, many lines + const chunks = splitMarkdownByBytes(markdown, 1024); + expect(chunks.length).toBeGreaterThan(1); }); }); -describe("countStepBlocks", () => { - test("counts thinking + tool_use, ignoring text", () => { - const content = [ - thinking("a"), - toolUse("Bash"), - textBlock("ignore me"), - toolUse("Read"), - ] as AssistantMessage["content"]; - expect(countStepBlocks(content)).toBe(3); +describe("splitMarkdownForCards", () => { + test("caps at 5 tables per chunk AND respects byte budget", () => { + const table = "| a | b |\n|---|---|\n| 1 | 2 |\n"; + const markdown = Array(12).fill(table).join("\n"); + const chunks = splitMarkdownForCards(markdown); + // 12 tables > 5 per chunk → at least 3 chunks + expect(chunks.length).toBeGreaterThanOrEqual(3); }); - test("returns 0 for text-only content", () => { - const content = [ - textBlock("one"), - textBlock("two"), - ] as AssistantMessage["content"]; - expect(countStepBlocks(content)).toBe(0); + test("MAX_MARKDOWN_BYTES_PER_CHUNK sits safely below Feishu's body cap", () => { + expect(MAX_MARKDOWN_BYTES_PER_CHUNK).toBeGreaterThan(0); + expect(MAX_MARKDOWN_BYTES_PER_CHUNK).toBeLessThan(30 * 1024); }); }); From 5d7ad417f6bd96634b2bfa60cc6b44ce6cc38123 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 21:53:49 +0800 Subject: [PATCH 31/69] feat(feishu): expose reply-quoted context; scope downloads to workspace Two related pieces that together make "reply-quote + @bot" work end to end: Reply-quoted context When a user @-mentions the bot (or messages in p2p / a bot-owned thread) while reply-quoting an earlier message, fetch that message via `im.message.get` and prepend it to the user turn as a structured block: ...up to 500 chars of the quoted text... Images in the quoted message get downloaded and embedded as `![quoted_image](path)` so Claude Code's multimodal pipeline picks them up. Other rich types (file, card, sticker, audio, video) are surfaced as human-readable placeholders so Claude at least knows what the user pointed at. Fetch failures (recalled, permission missing) render an "unavailable" block so the quote signal isn't silently lost. Workspace-scoped downloads `downloadMessageResource` previously wrote everything into the global `$AGENTARA_HOME/workspace/uploads` pool and returned a HOME-relative path. With per-chat workspaces that's wrong on both ends: Claude's cwd is `$AGENTARA_HOME/workspaces//`, so the relative path doesn't resolve, and artifacts from different chats get tangled in one pool. The channel now takes an optional `resolveWorkspaceCwd` callback injected by the kernel (`workspaceStore.resolve(chatId).cwd`). Each inbound event computes the per-chat target dir once and threads it through message parsing, post-element download, and quoted-resource extraction. Returned paths are absolute so they resolve regardless of cwd. `uploadImage` / `uploadFile` / `_extractLocalFilePaths` accept both absolute and legacy relative-to-HOME paths so older sessions still resume cleanly. --- .../feishu/messaging/message-channel.ts | 297 ++++++++++++++++-- .../feishu/messaging/message-renderer.ts | 17 +- src/kernel/kernel.ts | 6 + 3 files changed, 284 insertions(+), 36 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 5bc3b72..81cfdd3 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -61,6 +61,16 @@ export class FeishuMessageChannel private _inboundClient: WSClient; private _client: Client; private _db: DrizzleDB; + /** + * Optional workspace resolver injected by the kernel. Given a Feishu + * `chat_id`, returns the absolute cwd of the workspace bound to that + * chat (or the default workspace when unbound). Used as the download + * root for user-uploaded files & quoted resources so each bound chat + * keeps its artifacts inside its own workspace instead of a global + * pool. + */ + // eslint-disable-next-line no-unused-vars + private _resolveWorkspaceCwd?: (chatId: string | undefined) => string; private _failedCardUpdateMessages = new Set(); /** * Primary message ids for which we've already posted the final @@ -103,6 +113,8 @@ export class FeishuMessageChannel requireMention?: boolean; allowedUserOpenIds?: string[]; allowedUserEmails?: string[]; + // eslint-disable-next-line no-unused-vars + resolveWorkspaceCwd?: (chatId: string | undefined) => string; }, db: DrizzleDB, ) { @@ -114,6 +126,7 @@ export class FeishuMessageChannel this._db = db; this._logger = createLogger("feishu-message-channel"); this._requireMention = !!config.requireMention; + this._resolveWorkspaceCwd = config.resolveWorkspaceCwd; if (config.allowedUserOpenIds && config.allowedUserOpenIds.length > 0) { this._allowedUserOpenIds = new Set(config.allowedUserOpenIds); } @@ -759,7 +772,9 @@ export class FeishuMessageChannel * @returns The key of the uploaded image. */ async uploadImage(path: string): Promise { - const absPath = nodePath.join(config.paths.home, path); + const absPath = nodePath.isAbsolute(path) + ? path + : nodePath.join(config.paths.home, path); const file = fs.readFileSync(absPath); this._logger.info(`Uploading image ${absPath}`); const res = await this._client.im.v1.image.create({ @@ -780,11 +795,14 @@ export class FeishuMessageChannel /** * Uploads a file to Feishu. Returns the key of the uploaded file. - * @param filePath - The path to the file relative to the home directory. + * @param filePath - Absolute path, or a path relative to the home + * directory (legacy agent-generated markdown links). * @returns The key of the uploaded file. */ async uploadFile(filePath: string): Promise { - const absPath = nodePath.join(config.paths.home, filePath); + const absPath = nodePath.isAbsolute(filePath) + ? filePath + : nodePath.join(config.paths.home, filePath); const file = fs.createReadStream(absPath); const fileName = nodePath.basename(absPath); const ext = nodePath.extname(absPath).slice(1).toLowerCase(); @@ -825,14 +843,20 @@ export class FeishuMessageChannel * Downloads an image or a file from a message. * @param messageId - The ID of the message to download the resource from. * @param file_key - The key of the file to download. - * @param file_name - The name of the file to download. If not provided, the file name will be inferred from the file key. - * @returns The path to the downloaded file. + * @param options.file_name - Optional file name; inferred from metadata + * when omitted. + * @param options.targetDir - Absolute root directory for the download. + * Defaults to the global `$AGENTARA_HOME/workspace/uploads` when + * unspecified. Channel callers pass the per-chat workspace cwd so + * uploads land inside the workspace that triggered them. + * @returns Absolute path to the downloaded file. */ async downloadMessageResource( messageId: string, file_key: string, - file_name?: string, + options?: { file_name?: string; targetDir?: string }, ): Promise { + const { file_name, targetDir } = options ?? {}; const { writeFile, headers } = await this._client.im.v1.messageResource.get( { path: { @@ -851,7 +875,10 @@ export class FeishuMessageChannel Mime: string; }; const isImage = metadata.Mime.startsWith("image/"); - let dir = config.paths.uploads; + const root = targetDir + ? nodePath.join(targetDir, "uploads") + : config.paths.uploads; + let dir = root; if (isImage) { dir = nodePath.join(dir, "images"); } @@ -881,8 +908,13 @@ export class FeishuMessageChannel filename += `-${i}`; } filename += extname; - await writeFile(nodePath.join(dir, filename)); - return nodePath.relative(config.paths.home, nodePath.join(dir, filename)); + const absPath = nodePath.join(dir, filename); + await writeFile(absPath); + // Return an absolute path. Claude Code's cwd is the per-group + // workspace dir (`$AGENTARA_HOME/workspaces//`), not + // `$AGENTARA_HOME`, so a path relative to HOME would fail to + // resolve inside the agent's read tools. + return absPath; } /** @@ -971,11 +1003,11 @@ export class FeishuMessageChannel let match: RegExpExecArray | null; while ((match = linkRegex.exec(text)) !== null) { const filePath = match[1]; - if ( - filePath && - !filePath.includes("://") && - fs.existsSync(nodePath.join(config.paths.home, filePath)) - ) { + if (!filePath || filePath.includes("://")) continue; + const absPath = nodePath.isAbsolute(filePath) + ? filePath + : nodePath.join(config.paths.home, filePath); + if (fs.existsSync(absPath)) { paths.push(filePath); } } @@ -1084,6 +1116,7 @@ export class FeishuMessageChannel chat_id: chatId, chat_type: chatType, message_type: messageType, + parent_id: parentId, mentions, } = receivedMessage; const senderOpenId = sender?.sender_id?.open_id; @@ -1188,6 +1221,33 @@ export class FeishuMessageChannel name: m.name, }); } + // Per-chat download root so files land inside the workspace that + // actually triggered them. Falls back to the global uploads dir when + // the kernel didn't wire a resolver (tests, legacy callers). + const targetDir = this._resolveWorkspaceCwd + ? this._resolveWorkspaceCwd(chatId) + : undefined; + + const content: UserMessage["content"] = []; + // When the user reply-quoted an earlier message AND the new message + // is directed at the bot, surface the quoted context so Claude can + // see what they're actually pointing at. Skip when it's just an + // in-thread reply with no explicit quote signal to the bot. + if (parentId && isIntendedForBot) { + const info = await this._fetchQuotedMessage(parentId, targetDir); + content.push({ + type: "text", + text: _formatQuotedBlock(info, parentId), + }); + } + content.push( + await this._parseMessageContent( + messageId, + receivedMessage.message_type, + receivedMessage.content, + targetDir, + ), + ); const userMessage: UserMessage = { id: messageId, session_id, @@ -1199,13 +1259,7 @@ export class FeishuMessageChannel sender_open_id: senderOpenId, mentions: normalizedMentions.length > 0 ? normalizedMentions : undefined, - content: [ - await this._parseMessageContent( - messageId, - receivedMessage.message_type, - receivedMessage.content, - ), - ], + content, }; this.emit("message:inbound", userMessage); }; @@ -1363,10 +1417,124 @@ export class FeishuMessageChannel return uuid(); } + /** + * Pull the message the user reply-quoted so Claude can see what the new + * message is actually *about*. Returns a normalized `QuotedInfo` or + * `null` if the message can't be read (recalled, permission missing, + * network error) — callers should render a "消息已撤回或无法读取" + * placeholder in that case, not pretend the quote wasn't there. + */ + private async _fetchQuotedMessage( + parentId: string, + targetDir?: string, + ): Promise<_QuotedInfo | null> { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK types are thin here + const res = await (this._client.im.message as any).get({ + path: { message_id: parentId }, + }); + const item = res?.data?.items?.[0] as + | { + msg_type?: string; + create_time?: string; + sender?: { id?: string; id_type?: string }; + body?: { content?: string }; + } + | undefined; + if (!item) return null; + const msgType = item.msg_type ?? "unknown"; + const createdAt = item.create_time + ? Number(item.create_time) + : undefined; + const senderOpenId = + item.sender?.id_type === "open_id" ? item.sender.id : undefined; + const text = await this._extractQuotedText( + parentId, + msgType, + item.body?.content ?? "{}", + targetDir, + ); + return { + message_id: parentId, + message_type: msgType, + sender_open_id: senderOpenId, + created_at: createdAt, + text, + }; + } catch (err) { + this._logger.warn( + { err, message_id: parentId }, + "failed to fetch quoted message", + ); + return null; + } + } + + /** + * Reduce a quoted message to a single text representation. Text/post + * get extracted verbatim; opaque types (image, file, card, sticker…) + * get a human-readable placeholder so Claude can still reason about + * "what did the user point at" without needing to download the asset. + */ + private async _extractQuotedText( + messageId: string, + type: string, + rawContent: string, + targetDir?: string, + ): Promise { + let json: Record; + try { + json = JSON.parse(rawContent) as Record; + } catch { + return "[无法解析的消息内容]"; + } + if (type === "text") { + return typeof json.text === "string" ? json.text : ""; + } + if (type === "post") { + try { + return await convertPostToMarkdown(json, (file_key) => + this.downloadMessageResource(messageId, file_key, { targetDir }), + ); + } catch { + return "[富文本消息]"; + } + } + if (type === "image") { + // Download + embed as markdown image so Claude Code's multimodal + // pipeline picks it up, same way a directly-uploaded image works. + try { + const fileKey = + typeof json.image_key === "string" ? json.image_key : null; + if (!fileKey) return "[图片]"; + const path = await this.downloadMessageResource(messageId, fileKey, { + targetDir, + }); + return `![quoted_image](${path})`; + } catch (err) { + this._logger.warn( + { err, message_id: messageId }, + "failed to download quoted image", + ); + return "[图片(下载失败)]"; + } + } + if (type === "file") { + const name = typeof json.file_name === "string" ? json.file_name : ""; + return name ? `[文件: ${name}]` : "[文件]"; + } + if (type === "audio") return "[语音]"; + if (type === "media") return "[视频]"; + if (type === "sticker") return "[表情]"; + if (type === "interactive") return "[卡片]"; + return `[${type} 消息]`; + } + private async _parseMessageContent( messageId: string, type: string, content: string, + targetDir?: string, ): Promise { const json = JSON.parse(content); if (type === "text") { @@ -1375,9 +1543,8 @@ export class FeishuMessageChannel text: json.text, }; } else if (type === "post") { - const markdown = await convertPostToMarkdown( - json, - this.downloadMessageResource.bind(this, messageId), + const markdown = await convertPostToMarkdown(json, (file_key) => + this.downloadMessageResource(messageId, file_key, { targetDir }), ); return { type: "text", @@ -1385,7 +1552,9 @@ export class FeishuMessageChannel }; } else if (type === "image") { const file_key = json.image_key as string; - const path = await this.downloadMessageResource(messageId, file_key); + const path = await this.downloadMessageResource(messageId, file_key, { + targetDir, + }); return { type: "text", text: `![user_uploaded_image](${path})`, @@ -1393,11 +1562,10 @@ export class FeishuMessageChannel } else if (type === "file") { const file_key = json.file_key as string; const file_name = json.file_name as string; - const path = await this.downloadMessageResource( - messageId, - file_key, + const path = await this.downloadMessageResource(messageId, file_key, { file_name, - ); + targetDir, + }); return { type: "text", text: `A new file message uploaded to \`${path}\``, @@ -1409,6 +1577,77 @@ export class FeishuMessageChannel } } +/** + * Normalized shape of a reply-quoted Feishu message, ready to format + * into a `` block. + */ +interface _QuotedInfo { + message_id: string; + message_type: string; + sender_open_id?: string; + created_at?: number; + text: string; +} + +/** Max characters of quoted text surfaced to Claude. */ +const _QUOTED_TEXT_MAX_CHARS = 500; + +/** + * Format a quoted message as a structured `` block the + * agent can reason about. Pass `null` to render a "revoked / unreadable" + * placeholder block — the agent still sees that a quote existed, which + * is better than silently dropping the signal. + */ +function _formatQuotedBlock( + info: _QuotedInfo | null, + parentId?: string, +): string { + if (info === null) { + const idAttr = parentId ? ` id="${_escapeAttr(parentId)}"` : ""; + return ( + `\n` + + `[该消息已撤回或无法读取]\n` + + `` + ); + } + const attrs: string[] = [ + `id="${_escapeAttr(info.message_id)}"`, + `type="${_escapeAttr(info.message_type)}"`, + ]; + if (info.sender_open_id) { + attrs.push(`author_open_id="${_escapeAttr(info.sender_open_id)}"`); + } + if (info.created_at) { + attrs.push(`time="${new Date(info.created_at).toISOString()}"`); + } + const { text, truncated } = _truncateQuotedText( + info.text, + _QUOTED_TEXT_MAX_CHARS, + ); + if (truncated) attrs.push(`truncated="true"`); + return ( + `\n` + text + `\n` + ); +} + +/** Clip quoted text to a char budget; append an ellipsis when trimmed. */ +function _truncateQuotedText( + text: string, + maxChars: number, +): { text: string; truncated: boolean } { + if (text.length <= maxChars) return { text, truncated: false }; + return { text: text.slice(0, maxChars - 1) + "…", truncated: true }; +} + +/** Escape a string for safe use inside an XML attribute. */ +function _escapeAttr(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + /** * Cheap check for whether an inbound Feishu message looks like a slash * command, so we can skip the @-mention requirement for those. We only diff --git a/src/community/feishu/messaging/message-renderer.ts b/src/community/feishu/messaging/message-renderer.ts index e471ec5..56a067a 100644 --- a/src/community/feishu/messaging/message-renderer.ts +++ b/src/community/feishu/messaging/message-renderer.ts @@ -220,18 +220,21 @@ async function _uploadMessageResource( fs.mkdirSync(downloadPath, { recursive: true }); } if (imageName) { - fs.writeFileSync( - nodePath.join(downloadPath, imageName), - Buffer.from(imageBuffer), - ); - imagePath = nodePath.join("workspace", "downloads", imageName); + imagePath = nodePath.join(downloadPath, imageName); + fs.writeFileSync(imagePath, Buffer.from(imageBuffer)); } } catch { text = text.replaceAll(image, `[${imagePath}](${imagePath})`); } } - if (fs.existsSync(nodePath.join(config.paths.home, imagePath))) { - const imageKey = await uploadImage(imagePath); + // Paths may be absolute (Feishu downloads, since we moved away + // from relative-to-HOME) or relative-to-home (legacy sessions, + // markdown written by agents using the old scheme). + const absPath = nodePath.isAbsolute(imagePath) + ? imagePath + : nodePath.join(config.paths.home, imagePath); + if (fs.existsSync(absPath)) { + const imageKey = await uploadImage(absPath); text = text.replaceAll(image, `![image](${imageKey})`); } else { text = text.replaceAll(image, ""); diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 505a7ba..d3f1039 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -136,6 +136,12 @@ class Kernel { allowedOpenIds.length > 0 ? allowedOpenIds : undefined, allowedUserEmails: allowedEmails.length > 0 ? allowedEmails : undefined, + // Uploads and quoted-resource downloads land inside the workspace + // the chat is currently bound to, so each chat's artifacts stay + // co-located with its session instead of piling up in a shared + // `$AGENTARA_HOME/workspace/uploads` pool. + resolveWorkspaceCwd: (chatId) => + this._workspaceStore.resolve(chatId).cwd, }, this._database.db, ); From ef23134ed9e7b3ba1c9bc2a481eb0c595f4463f4 Mon Sep 17 00:00:00 2001 From: xluos Date: Tue, 21 Apr 2026 22:26:20 +0800 Subject: [PATCH 32/69] feat(feishu): note response duration on finalized cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track wall-clock start of each streaming reply on the channel, then render a small grey "Done in Xs" note at the bottom of the card on the final (non-streaming) patch. Non-streaming one-shots (postMessage, reply without streaming) get no note — they have no meaningful streaming window. --- .../feishu/messaging/message-channel.ts | 20 +++++++++++ .../feishu/messaging/message-renderer.ts | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 81cfdd3..19513d9 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -78,6 +78,13 @@ export class FeishuMessageChannel * update doesn't double-post continuations. */ private _finalizedPrimaries = new Set(); + /** + * Wall-clock start timestamps of in-flight streaming cards, keyed by + * primary message id. Set when the first streaming reply is created and + * consumed on the final (`streaming: false`) patch so we can render a + * "Done in Xs" note at the bottom of the finalized card. + */ + private _cardStartedAt = new Map(); private _logger: Logger; private _requireMention: boolean; private _botOpenId?: string; @@ -622,6 +629,10 @@ export class FeishuMessageChannel this._mapThreadToSession(replyMessage.thread_id, message.session_id); } + if (streaming) { + this._cardStartedAt.set(primaryId, Date.now()); + } + if (!streaming && markdownContinuations.length > 0) { await this._postMarkdownContinuations( primaryId, @@ -724,9 +735,15 @@ export class FeishuMessageChannel this._logOutboundMessage(message.session_id, message.content); } + const startedAt = this._cardStartedAt.get(message.id); + const elapsedMs = + !streaming && typeof startedAt === "number" + ? Date.now() - startedAt + : undefined; const card = await renderMessageCard(primaryContent, { streaming, uploadImage: this.uploadImage.bind(this), + elapsedMs, }); try { await this._client.im.message.patch({ @@ -745,6 +762,9 @@ export class FeishuMessageChannel } throw err; } + if (!streaming) { + this._cardStartedAt.delete(message.id); + } // Markdown continuations are only meaningful once the run is final — // during streaming the "final text" hasn't stabilized yet. Post them diff --git a/src/community/feishu/messaging/message-renderer.ts b/src/community/feishu/messaging/message-renderer.ts index 56a067a..88a83e5 100644 --- a/src/community/feishu/messaging/message-renderer.ts +++ b/src/community/feishu/messaging/message-renderer.ts @@ -71,6 +71,10 @@ const _SUMMARY_PREVIEW_CHARS = 180; * Render assistant message content as a Feishu interactive card. * @param messageContent - Array of content blocks (thinking, tool_use, text). * @param options - Rendering options (streaming mode). + * - `elapsedMs`: optional response-duration hint in milliseconds. When + * provided and `streaming` is false, the card renders a small grey + * note at the bottom like "Done in 12.8s" so users can see how long + * the run took. * @returns Feishu Card object for API payload. */ export async function renderMessageCard( @@ -78,10 +82,12 @@ export async function renderMessageCard( { streaming, uploadImage, + elapsedMs, }: { streaming: boolean; // eslint-disable-next-line no-unused-vars uploadImage: (path: string) => Promise; + elapsedMs?: number; }, ): Promise { const stepPanel: CollapsiblePanel = { @@ -189,10 +195,40 @@ export async function renderMessageCard( color: "grey", }, }); + } else if (typeof elapsedMs === "number" && elapsedMs >= 0) { + card.body.elements.push({ + tag: "div", + text: { + tag: "plain_text", + text_color: "grey", + text_size: "notation", + content: `Done in ${_formatDuration(elapsedMs)}`, + }, + }); } return card; } +/** + * Human-friendly duration string for the card's "Done in …" note. + * Sub-second runs round to 0.1s; sub-minute shows a single decimal; + * minutes drop the decimal and pad seconds; hours add an `h` prefix. + */ +function _formatDuration(ms: number): string { + const totalSeconds = ms / 1000; + if (totalSeconds < 60) { + return `${totalSeconds.toFixed(1)}s`; + } + const totalSecondsInt = Math.floor(totalSeconds); + const hours = Math.floor(totalSecondsInt / 3600); + const minutes = Math.floor((totalSecondsInt % 3600) / 60); + const seconds = totalSecondsInt % 60; + if (hours === 0) { + return `${minutes}m ${seconds}s`; + } + return `${hours}h ${minutes}m ${seconds}s`; +} + async function _uploadMessageResource( text: string, { From f258e24fbcd806f47ac4d93eb8d2f35ea7d2512f Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 15:44:13 +0800 Subject: [PATCH 33/69] feat(feishu): add /mute and /unmute to toggle thread auto-respond Bot-participated threads now require @-mention by default so humans can side-chat in the same topic without the bot interrupting. Operators can flip a thread into auto-respond mode with /unmute and back with /mute; state is persisted per thread on feishu_threads.auto_respond. --- drizzle/0014_closed_loki.sql | 1 + drizzle/meta/0014_snapshot.json | 415 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/community/feishu/messaging/data/schema.ts | 7 + .../feishu/messaging/message-channel.ts | 119 +++-- src/kernel/commands/handlers.ts | 62 +++ 6 files changed, 581 insertions(+), 30 deletions(-) create mode 100644 drizzle/0014_closed_loki.sql create mode 100644 drizzle/meta/0014_snapshot.json diff --git a/drizzle/0014_closed_loki.sql b/drizzle/0014_closed_loki.sql new file mode 100644 index 0000000..04ccaf8 --- /dev/null +++ b/drizzle/0014_closed_loki.sql @@ -0,0 +1 @@ +ALTER TABLE `feishu_threads` ADD `auto_respond` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..c0b4aad --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,415 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "53369f63-7a3f-4ee0-ad17-377fad384943", + "prevId": "da37cbce-6bd1-4f48-8180-8f958e06cd7c", + "tables": { + "group_workspaces": { + "name": "group_workspaces", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_repo": { + "name": "active_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_branch": { + "name": "active_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_path_unique": { + "name": "workspaces_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_bot_groups": { + "name": "feishu_bot_groups", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_open_id": { + "name": "creator_open_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_respond": { + "name": "auto_respond", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f19f61e..68c503d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1776695439707, "tag": "0013_steady_korvac", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1776842966838, + "tag": "0014_closed_loki", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/community/feishu/messaging/data/schema.ts b/src/community/feishu/messaging/data/schema.ts index 93cce48..ab8b439 100644 --- a/src/community/feishu/messaging/data/schema.ts +++ b/src/community/feishu/messaging/data/schema.ts @@ -15,6 +15,13 @@ export const feishuThreads = sqliteTable("feishu_threads", { session_id: text("session_id").notNull(), /** Epoch milliseconds when the mapping was created. */ created_at: integer("created_at").notNull(), + /** + * When 1, every message inside this thread triggers the bot without + * requiring an @-mention. When 0 (default), group-chat messages must + * still @-mention the bot even inside a thread the bot already + * participates in. Toggle via `/unmute` / `/mute` in the thread. + */ + auto_respond: integer("auto_respond").notNull().default(0), }); /** diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 19513d9..16ba926 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -1152,16 +1152,17 @@ export class FeishuMessageChannel messageType, receivedMessage.content, ); - // Messages inside a thread the bot has already engaged in are implicitly - // directed at the bot — no need to @-mention again. The `feishu_threads` - // table tracks every thread the bot has participated in (either by - // starting it via reply/post, or by being @-mentioned into it earlier). - const isInBotThread = this._isInBotThread(threadId); + // Messages inside a thread the bot has already engaged in still require + // an @-mention by default — otherwise casual in-thread chatter between + // humans would make the bot reply to every line. Operators can flip the + // thread into "auto-respond" mode via `/unmute`, which is what this flag + // reflects. `/mute` restores the default. + const isThreadAutoRespond = this._isThreadAutoRespond(threadId); const mentionEnforced = this._requireMention && chatType === "group" && !isSlashCommand && - !isInBotThread; + !isThreadAutoRespond; const isBotMentioned = !!this._botOpenId && !!mentions?.some((m) => m.id?.open_id === this._botOpenId); @@ -1176,21 +1177,21 @@ export class FeishuMessageChannel sender_open_id: senderOpenId, bot_mentioned: isBotMentioned, slash_command: isSlashCommand, - in_bot_thread: isInBotThread, + thread_auto_respond: isThreadAutoRespond, passed: isAllowedSender && mentionOk, }, "inbound message", ); - // A message is "directed at the bot" if the intent is unambiguous — slash - // command, explicit @-mention, inside a thread the bot already owns, or a - // p2p chat. We only surface rejection replies for these; casual group - // chatter from non-whitelisted members gets silently dropped to avoid - // spamming the chat. + // A message is "directed at the bot" if the intent is unambiguous — + // slash command, explicit @-mention, inside an auto-respond thread, or + // a p2p chat. We only surface rejection replies for these; casual + // non-whitelisted chatter inside a muted bot-thread is silently dropped + // so the bot doesn't interrupt human side-discussions. const isIntendedForBot = isSlashCommand || isBotMentioned || - isInBotThread || + isThreadAutoRespond || chatType === "p2p"; if (!isAllowedSender) { @@ -1360,39 +1361,92 @@ export class FeishuMessageChannel }; }; - private _threadIdToSessionId = new Map(); + private _threadState = new Map< + string, + { session_id: string; auto_respond: boolean } + >(); /** - * Returns true if `threadId` belongs to a thread the bot has engaged in - * before (either by starting it via reply/post, or by being @-mentioned - * into it). Used to bypass the @-mention requirement for follow-up - * messages inside a bot-owned topic. Cheap: in-memory cache first, then - * indexed single-row lookup on `feishu_threads`. + * Returns true when the thread is explicitly opted into "auto respond" + * mode, i.e. the operator ran `/unmute` in it. Default for every + * bot-participated thread is false — group messages must still @-mention + * the bot even inside a bot-owned topic. Cheap: in-memory cache first, + * then indexed single-row lookup on `feishu_threads`. */ - private _isInBotThread(threadId: string | undefined): boolean { + private _isThreadAutoRespond(threadId: string | undefined): boolean { if (!threadId) return false; - if (this._threadIdToSessionId.has(threadId)) return true; + const cached = this._threadState.get(threadId); + if (cached) return cached.auto_respond; const row = this._db - .select({ session_id: feishuThreads.session_id }) + .select({ + session_id: feishuThreads.session_id, + auto_respond: feishuThreads.auto_respond, + }) .from(feishuThreads) .where(eq(feishuThreads.thread_id, threadId)) .get(); if (row) { - this._threadIdToSessionId.set(threadId, row.session_id); - return true; + const state = { + session_id: row.session_id, + auto_respond: row.auto_respond === 1, + }; + this._threadState.set(threadId, state); + return state.auto_respond; } return false; } + /** + * Flip the auto-respond flag on a thread. Persists to DB and updates the + * in-memory cache so the change takes effect on the very next inbound + * message. Creates the row if missing, though typically the thread has + * already been mapped by {@link _mapThreadToSession} via an earlier bot + * reply. Returns the resolved session id for the thread. + */ + setThreadAutoRespond( + threadId: string, + enabled: boolean, + fallbackSessionId: string, + ): string { + const current = this._threadState.get(threadId); + const sessionId = current?.session_id ?? fallbackSessionId; + this._threadState.set(threadId, { + session_id: sessionId, + auto_respond: enabled, + }); + const flag = enabled ? 1 : 0; + this._db + .insert(feishuThreads) + .values({ + thread_id: threadId, + session_id: sessionId, + created_at: Date.now(), + auto_respond: flag, + }) + .onConflictDoUpdate({ + target: feishuThreads.thread_id, + set: { auto_respond: flag }, + }) + .run(); + return sessionId; + } + /** Persist a thread→session mapping to DB and update the in-memory cache. */ private _mapThreadToSession(threadId: string, sessionId: string) { - this._threadIdToSessionId.set(threadId, sessionId); + const existing = this._threadState.get(threadId); + if (!existing) { + this._threadState.set(threadId, { + session_id: sessionId, + auto_respond: false, + }); + } this._db .insert(feishuThreads) .values({ thread_id: threadId, session_id: sessionId, created_at: Date.now(), + auto_respond: 0, }) .onConflictDoNothing() .run(); @@ -1415,17 +1469,22 @@ export class FeishuMessageChannel chatId: string | undefined, threadId: string | undefined, ): string { - if (threadId && this._threadIdToSessionId.has(threadId)) { - return this._threadIdToSessionId.get(threadId)!; - } if (threadId) { + const cached = this._threadState.get(threadId); + if (cached) return cached.session_id; const row = this._db - .select({ session_id: feishuThreads.session_id }) + .select({ + session_id: feishuThreads.session_id, + auto_respond: feishuThreads.auto_respond, + }) .from(feishuThreads) .where(eq(feishuThreads.thread_id, threadId)) .get(); if (row) { - this._threadIdToSessionId.set(threadId, row.session_id); + this._threadState.set(threadId, { + session_id: row.session_id, + auto_respond: row.auto_respond === 1, + }); return row.session_id; } if (chatId) { diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 7bc1dea..72e393a 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -448,6 +448,66 @@ const allowHandler: CommandHandler = { }, }; +/** + * Toggle "auto respond" on the current thread. Default (muted) is must-@: + * inside a bot-created thread the bot still requires @-mention to reply, + * so humans can use the same thread to discuss with each other without + * the bot jumping in. `/unmute` flips the thread into auto-respond mode; + * `/mute` restores the default. + */ +function buildMuteHandler( + name: "mute" | "unmute", + description: string, + enabled: boolean, +): CommandHandler { + const verb = enabled ? "解除静音" : "静音"; + const stateLine = enabled + ? "✅ 已取消静音:本话题内的消息无需 @ 机器人也会响应。" + : "✅ 已静音:本话题内需要 @ 机器人后才会响应。"; + return { + name, + description, + async execute(ctx) { + if (ctx.message.chat_type !== "group") { + return `❌ /${name} 仅在群聊话题内可用(单聊本就不需要 @)。`; + } + const threadId = ctx.message.thread_id; + if (!threadId) { + return `❌ /${name} 需要在一个话题里执行;请在机器人发起的话题内回复此命令。`; + } + if (!ctx.message.channel_id) { + return `❌ /${name} 需要飞书会话上下文。`; + } + const channel = ctx.feishuChannels.get(ctx.message.channel_id); + if (!channel) { + return "❌ 找不到对应的飞书 channel。"; + } + channel.setThreadAutoRespond(threadId, enabled, ctx.message.session_id); + ctx.logger.info( + { + thread_id: threadId, + session_id: ctx.message.session_id, + auto_respond: enabled, + }, + "thread auto-respond toggled", + ); + return cardReply(verb, [stateLine]); + }, + }; +} + +const muteHandler = buildMuteHandler( + "mute", + "/mute — 关闭本话题的免 @ 自动响应(恢复默认:必须 @ 机器人)", + false, +); + +const unmuteHandler = buildMuteHandler( + "unmute", + "/unmute — 开启本话题的免 @ 自动响应(机器人自动接话)", + true, +); + export const helpHandler: CommandHandler = { name: "help", description: "/help — 显示所有可用命令", @@ -480,6 +540,8 @@ export const BUILTIN_COMMANDS: CommandHandler[] = [ checkoutHandler, ungroupHandler, allowHandler, + muteHandler, + unmuteHandler, ]; function _formatSyncLine(r: RepoSyncResult): string { From 091f11bae81dda7a5b17b7b89cdca7aff5ae5680 Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 15:45:32 +0800 Subject: [PATCH 34/69] feat(feishu): allow /group with no mentions to create a 2-person bot chat Drop the "at least one @mention" guard in /group argument parsing so a bot + sender pair is treated as a valid group. Matches the common case where the user just wants a workspace-bound private chat for themselves. --- src/kernel/group/group-flow.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/kernel/group/group-flow.ts b/src/kernel/group/group-flow.ts index 28ace83..db00530 100644 --- a/src/kernel/group/group-flow.ts +++ b/src/kernel/group/group-flow.ts @@ -185,13 +185,10 @@ export class GroupFlow { ): | { name: string; memberOpenIds: string[] } | { error: string } { + // Empty mention list is fine: a bot + sender 2-person group is a valid + // Feishu chat and the primary way `/group` is used when the user only + // wants a workspace-bound chat for themselves. const mentions = message.mentions ?? []; - if (mentions.length === 0) { - return { - error: - "用法:`/group <群名> @user1 @user2 ...`(至少 @ 一个人)", - }; - } const seen = new Set(); const memberOpenIds: string[] = []; for (const m of mentions) { @@ -204,9 +201,6 @@ export class GroupFlow { seen.add(m.open_id); memberOpenIds.push(m.open_id); } - if (memberOpenIds.length === 0) { - return { error: "❌ 没有识别到被 @ 的其他成员(自己不算)。" }; - } // Name is everything between `/group ` and the first mention placeholder. // Use the message's text content verbatim — mentions carry `key` From c26e1bde0c4829aaa28c71b899dc219f246bfb90 Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 16:03:07 +0800 Subject: [PATCH 35/69] fix(feishu): don't prepend quoted block for slash commands When a user reply-quotes inside a bot thread and sends a slash command, the channel was prepending a block to the content. The joined text then started with "<" instead of "/", so parseCommand couldn't route it and the command got dispatched to the agent. Slash commands operate on gateway-level state and don't need reply context, so skip the quote prepend when the message is a slash command. --- src/community/feishu/messaging/message-channel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 16ba926..0df693e 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -1253,8 +1253,11 @@ export class FeishuMessageChannel // When the user reply-quoted an earlier message AND the new message // is directed at the bot, surface the quoted context so Claude can // see what they're actually pointing at. Skip when it's just an - // in-thread reply with no explicit quote signal to the bot. - if (parentId && isIntendedForBot) { + // in-thread reply with no explicit quote signal to the bot. Slash + // commands are gateway-level and operate on bot state — prepending + // the quoted block would corrupt the leading `/…` and prevent the + // kernel from routing it. + if (parentId && isIntendedForBot && !isSlashCommand) { const info = await this._fetchQuotedMessage(parentId, targetDir); content.push({ type: "text", From cc73c8ffc1dd742f887b1e2b82cda6589fd15182 Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 16:06:50 +0800 Subject: [PATCH 36/69] fix(feishu): route slash commands when user @s the bot inside a thread Users habitually @-mention the bot before typing a command in a Feishu thread, producing text like "@_user_1 /unmute". Two gaps let those slip past: - _peekSlashCommand matched the literal "/" prefix, so the @-prefixed form wasn't recognized as a slash command (only mention-bypass kept the message flowing). - kernel stripped @_user_N placeholders only on session start; inside an existing thread the leading placeholder stuck around, so the text didn't start with "/" and the command routed to the agent instead of the handler. Strip leading @_user_N runs in both places. Non-leading placeholders are left intact so real mid-message @-mentions aren't mangled. --- .../feishu/messaging/message-channel.ts | 8 +++++++- src/kernel/kernel.ts | 20 ++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 0df693e..239c127 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -1740,7 +1740,13 @@ function _peekSlashCommand(type: string, content: string): boolean { if (type !== "text") return false; try { const json = JSON.parse(content) as { text?: unknown }; - const text = typeof json.text === "string" ? json.text.trimStart() : ""; + if (typeof json.text !== "string") return false; + // Strip leading @_user_N placeholder runs — users habitually @ the + // bot before typing a slash command inside a thread, and the + // placeholder would otherwise hide the `/` prefix. + const text = json.text + .trimStart() + .replace(/^(?:@_user_\d+\s*)+/, ""); return /^\/[a-zA-Z]/.test(text); } catch { return false; diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index d3f1039..4693638 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -186,18 +186,24 @@ class Kernel { } private _handleInboundMessage = async (message: UserMessage) => { - // Feishu substitutes @mentions as `@_user_N` placeholders. Strip them - // ONLY on the first message of a session (the user @-summoning the bot - // to start a thread) so that `@bot /bind foo` routes through the slash - // command path. Subsequent messages inside the same thread keep their - // placeholders intact so real @-mentions of other users aren't mangled. + // Feishu substitutes @mentions as `@_user_N` placeholders. Two-tier + // stripping: + // - Always drop LEADING placeholder runs so users can `@bot /foo` in + // any context (new session, inside a thread, etc.) and still hit + // gateway-level slash routing. + // - On the first message of a session, strip ALL placeholders — the + // whole line is the user summoning the bot; nothing else in it + // references a real collaborator. + // Non-leading placeholders are preserved mid-session so real @-mentions + // (e.g. `/allow @other_user`, or regular chatter) aren't mangled. const isSessionStart = !this._sessionManager.existsSession( message.session_id, ); const rawText = extractTextContent(message); + const trimmed = rawText.trim().replace(/^(?:@_user_\d+\s*)+/, ""); const text = isSessionStart - ? rawText.replace(/@_user_\d+/g, "").trim() - : rawText.trim(); + ? trimmed.replace(/@_user_\d+/g, "").trim() + : trimmed.trim(); // Handle /stop command (kernel-owned because it talks to TaskDispatcher) if (text === "/stop") { From cad00f7d1cac9809652db262501003fc902d058a Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 18:20:59 +0800 Subject: [PATCH 37/69] feat(kernel): tighten slash command routing + add /new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject `/`-prefixed messages that don't match a registered command instead of forwarding them to the agent. A typo'd `/halp` now replies with an "unknown command" card pointing at /help, rather than wasting a turn on an LLM that has no idea what to do with it. - Add `/new ` as a kernel-owned command that forks a fresh session bound to a brand-new Feishu thread, with the post-slash text as the first agent prompt. Bare `/new` returns a usage card; running it inside an existing thread is rejected — Feishu threads don't nest, so replying in-thread would silently fold the "new" session into the old thread and overwrite its thread→session mapping. - Extract pure helpers (new-command.ts, unknown-command.ts) so the parsing, message rewriting, and card construction are unit-testable without instantiating the full kernel. - Add unit tests for parseCommand, the /new helpers, and the unknown command reply builder. --- src/kernel/commands/handlers.ts | 1 + src/kernel/commands/index.ts | 2 + src/kernel/commands/new-command.ts | 82 +++++++++++ src/kernel/commands/unknown-command.ts | 26 ++++ src/kernel/kernel.ts | 77 +++++++++- tests/kernel/commands/new-command.test.ts | 134 ++++++++++++++++++ tests/kernel/commands/parser.test.ts | 49 +++++++ tests/kernel/commands/unknown-command.test.ts | 37 +++++ 8 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 src/kernel/commands/new-command.ts create mode 100644 src/kernel/commands/unknown-command.ts create mode 100644 tests/kernel/commands/new-command.test.ts create mode 100644 tests/kernel/commands/parser.test.ts create mode 100644 tests/kernel/commands/unknown-command.test.ts diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 72e393a..7b76d55 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -523,6 +523,7 @@ export const helpHandler: CommandHandler = { "- /setup — 打开 workspace 配置卡片(仅群聊)", "- /switch — 打开 workspace 切换卡片(群聊 & 单聊)", "- /group <群名> @user... — 机器人建群并自动 /setup(仅单聊)", + "- /new <消息> — 开启新会话 + 新话题(须在主群,非话题内)", ], }, ], diff --git a/src/kernel/commands/index.ts b/src/kernel/commands/index.ts index cfff7de..e4f7375 100644 --- a/src/kernel/commands/index.ts +++ b/src/kernel/commands/index.ts @@ -1,4 +1,6 @@ export * from "./parser"; export * from "./registry"; export * from "./types"; +export * from "./new-command"; +export * from "./unknown-command"; export { BUILTIN_COMMANDS, helpHandler } from "./handlers"; diff --git a/src/kernel/commands/new-command.ts b/src/kernel/commands/new-command.ts new file mode 100644 index 0000000..c67b01e --- /dev/null +++ b/src/kernel/commands/new-command.ts @@ -0,0 +1,82 @@ +import type { UserMessage } from "@/shared"; + +import type { Card } from "../../community/feishu/messaging/types"; + +import { buildCommandCard } from "./cards"; + +/** + * Helpers for the kernel-owned `/new` command. Extracted from `Kernel` + * so the parsing, message rewriting, and card construction can be unit + * tested without instantiating the full kernel graph. + */ + +const NEW_COMMAND_PREFIX_WITH_SPACE = "/new "; + +/** + * Whether the given inbound text is a `/new` invocation (with or + * without arguments). + */ +export function isNewCommand(text: string): boolean { + return text === "/new" || text.startsWith(NEW_COMMAND_PREFIX_WITH_SPACE); +} + +/** + * Extract the prompt that follows `/new `. Returns an empty string + * for bare `/new` or whitespace-only args — callers should surface + * the usage card in that case. + */ +export function extractNewPrompt(text: string): string { + if (!isNewCommand(text)) return ""; + if (text === "/new") return ""; + return text.slice(NEW_COMMAND_PREFIX_WITH_SPACE.length).trim(); +} + +/** + * Rewrite an inbound user message as the first turn of a brand-new + * session in a brand-new Feishu thread. Drops `thread_id` so the + * subsequent `replyMessage(replyInThread:true)` creates a fresh + * thread instead of landing inside the thread the `/new` was typed + * from; replaces `content` with just the prompt so the agent doesn't + * see the `/new` wrapper. + */ +export function createFreshUserMessage( + original: UserMessage, + prompt: string, + sessionId: string, +): UserMessage { + return { + ...original, + session_id: sessionId, + thread_id: undefined, + content: [{ type: "text", text: prompt }], + }; +} + +export interface KernelCommandReply { + text: string; + card: Card; +} + +export function buildNewCommandRejectionReply(): KernelCommandReply { + const lines = [ + "❌ /new 需要在主群里使用。", + "- 当前已在话题中,飞书话题不支持嵌套。", + "- 请回到主群再执行 `/new <消息>`。", + ]; + return { + text: "❌ /new 需要在主群(非话题内)使用;当前已在话题里,无法再嵌套新建。", + card: buildCommandCard({ title: "新会话", lines }), + }; +} + +export function buildNewCommandUsageReply(): KernelCommandReply { + const lines = [ + "用法:`/new <消息>`", + "- 等价于在群里 @bot 开启一个新会话 + 新话题。", + "- 斜杠后的内容会作为第一条发给 agent 的消息。", + ]; + return { + text: "用法:/new <消息>", + card: buildCommandCard({ title: "新会话", lines }), + }; +} diff --git a/src/kernel/commands/unknown-command.ts b/src/kernel/commands/unknown-command.ts new file mode 100644 index 0000000..b878ee2 --- /dev/null +++ b/src/kernel/commands/unknown-command.ts @@ -0,0 +1,26 @@ +import { buildCommandCard } from "./cards"; +import type { KernelCommandReply } from "./new-command"; +import { parseCommand } from "./parser"; + +/** + * Build the "unknown command" reply for `/`-prefixed messages that + * don't map to any registered handler. Kernel uses this as the + * fallback so unrecognized slashes fail loudly instead of being + * forwarded to the agent. + */ +export function buildUnknownCommandReply(text: string): KernelCommandReply { + const parsed = parseCommand(text); + const lines = parsed + ? [ + `❌ \`/${parsed.name}\` 不是已注册的命令。`, + "- 使用 `/help` 查看所有可用命令。", + ] + : [ + "❌ 命令格式不正确(斜杠后需要跟命令名)。", + "- 使用 `/help` 查看所有可用命令。", + ]; + return { + text: lines.join("\n"), + card: buildCommandCard({ title: "未知命令", lines }), + }; +} diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 4693638..6250d01 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -18,7 +18,17 @@ import { import { HonoServer } from "../server"; -import { CommandRegistry, parseCommand, type CardCommandResult } from "./commands"; +import { + buildNewCommandRejectionReply, + buildNewCommandUsageReply, + buildUnknownCommandReply, + CommandRegistry, + createFreshUserMessage, + extractNewPrompt, + isNewCommand, + parseCommand, + type CardCommandResult, +} from "./commands"; import { buildCommandCard } from "./commands/cards"; import { GroupFlow } from "./group/group-flow"; import { MultiChannelMessageGateway } from "./messaging"; @@ -233,10 +243,23 @@ class Kernel { return; } - // Try gateway-level slash commands before dispatching to the LLM. + // Handle /new command (kernel-owned — forks a fresh session + fresh + // Feishu thread, optionally carrying the post-slash text as the first + // agent prompt). Equivalent to @-mentioning the bot in the main chat. + if (isNewCommand(text)) { + await this._handleNewCommand(message, text); + return; + } + + // Gateway-level slash commands: any `/`-prefixed message must be + // resolved here. If it's not a registered command, reply with an + // error rather than forwarding to the agent — passing `/typo` on + // to the LLM wastes a turn and confuses users who just mistyped + // a command name. if (text.startsWith("/")) { const handled = await this._tryHandleCommand(message, text); - if (handled) return; + if (!handled) await this._replyUnknownCommand(message, text); + return; } // On the first message of a new session, kick off a best-effort @@ -286,6 +309,54 @@ class Kernel { }); } + private _handleNewCommand = async (message: UserMessage, text: string) => { + // Feishu threads don't nest: replying to an already-threaded message + // with reply_in_thread:true stays in the same thread. Running /new + // inside a thread would silently fold the "new" session into the + // existing one and overwrite its thread→session mapping. Reject. + if (message.thread_id) { + const reply = buildNewCommandRejectionReply(); + await this._replyTextOrCard(message, reply.text, reply.card, "new"); + return; + } + + const prompt = extractNewPrompt(text); + if (!prompt) { + const reply = buildNewCommandUsageReply(); + await this._replyTextOrCard(message, reply.text, reply.card, "new"); + return; + } + + // Fork: fresh session_id so isSessionStart flips true, and + // thread_id=undefined so replyMessage(replyInThread:true) creates a + // brand-new Feishu thread rooted at this user message. + const newMessage = createFreshUserMessage(message, prompt, uuid()); + this._logger.info( + { + old_session_id: message.session_id, + new_session_id: newMessage.session_id, + chat_id: message.chat_id, + message_id: message.id, + }, + "/new starting fresh session + thread", + ); + if (newMessage.chat_id) { + this._autoSyncOnSessionStart(newMessage.chat_id); + } + await this._taskDispatcher.dispatch(newMessage.session_id, { + type: "inbound_message", + message: newMessage, + }); + }; + + private _replyUnknownCommand = async ( + message: UserMessage, + text: string, + ): Promise => { + const reply = buildUnknownCommandReply(text); + await this._replyTextOrCard(message, reply.text, reply.card, "unknown"); + }; + private _tryHandleCommand = async ( message: UserMessage, text: string, diff --git a/tests/kernel/commands/new-command.test.ts b/tests/kernel/commands/new-command.test.ts new file mode 100644 index 0000000..9c9014b --- /dev/null +++ b/tests/kernel/commands/new-command.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildNewCommandRejectionReply, + buildNewCommandUsageReply, + createFreshUserMessage, + extractNewPrompt, + isNewCommand, +} from "@/kernel/commands"; +import type { UserMessage } from "@/shared"; + +function _makeUserMessage(overrides: Partial = {}): UserMessage { + return { + id: "msg_1", + role: "user", + session_id: "old_session", + channel_id: "ch_1", + chat_id: "oc_1", + chat_type: "group", + thread_id: undefined, + sender_open_id: "ou_alice", + mentions: [], + content: [{ type: "text", text: "/new hello" }], + ...overrides, + }; +} + +describe("isNewCommand", () => { + test("matches bare /new and /new with args", () => { + expect(isNewCommand("/new")).toBe(true); + expect(isNewCommand("/new hello")).toBe(true); + expect(isNewCommand("/new multi word prompt")).toBe(true); + }); + + test("rejects slashes that only share the prefix", () => { + expect(isNewCommand("/newz")).toBe(false); + expect(isNewCommand("/newhello")).toBe(false); + expect(isNewCommand("/ new")).toBe(false); + }); + + test("rejects non-/new input", () => { + expect(isNewCommand("hello /new")).toBe(false); + expect(isNewCommand("/")).toBe(false); + expect(isNewCommand("")).toBe(false); + }); +}); + +describe("extractNewPrompt", () => { + test("returns empty string for bare /new", () => { + expect(extractNewPrompt("/new")).toBe(""); + }); + + test("returns empty when args are only whitespace", () => { + expect(extractNewPrompt("/new ")).toBe(""); + expect(extractNewPrompt("/new ")).toBe(""); + }); + + test("trims outer whitespace but preserves inner spacing", () => { + expect(extractNewPrompt("/new hello world")).toBe("hello world"); + expect(extractNewPrompt("/new hello ")).toBe("hello"); + expect(extractNewPrompt("/new hello world")).toBe("hello world"); + }); + + test("returns empty for inputs that are not /new", () => { + expect(extractNewPrompt("/newz hello")).toBe(""); + expect(extractNewPrompt("hello")).toBe(""); + expect(extractNewPrompt("")).toBe(""); + }); +}); + +describe("createFreshUserMessage", () => { + test("overrides session_id, clears thread_id, replaces content", () => { + const original = _makeUserMessage({ + session_id: "old_session", + thread_id: "om_thread_1", + }); + const fresh = createFreshUserMessage(original, "hello", "new_session"); + + expect(fresh.session_id).toBe("new_session"); + expect(fresh.thread_id).toBeUndefined(); + expect(fresh.content).toEqual([{ type: "text", text: "hello" }]); + }); + + test("preserves id, channel_id, chat_id, role, chat_type, sender_open_id", () => { + const original = _makeUserMessage(); + const fresh = createFreshUserMessage(original, "hello", "s2"); + + expect(fresh.id).toBe(original.id); + expect(fresh.channel_id).toBe(original.channel_id); + expect(fresh.chat_id).toBe(original.chat_id); + expect(fresh.chat_type).toBe(original.chat_type); + expect(fresh.sender_open_id).toBe(original.sender_open_id); + expect(fresh.role).toBe("user"); + }); + + test("does not mutate the original message", () => { + const original = _makeUserMessage({ thread_id: "om_t1" }); + const snapshot = JSON.stringify(original); + createFreshUserMessage(original, "hello", "s2"); + expect(JSON.stringify(original)).toBe(snapshot); + }); + + test("clears thread_id even when original had none", () => { + const original = _makeUserMessage({ thread_id: undefined }); + const fresh = createFreshUserMessage(original, "hi", "s_new"); + expect(fresh.thread_id).toBeUndefined(); + expect(fresh.session_id).toBe("s_new"); + }); +}); + +describe("buildNewCommandRejectionReply", () => { + test("fallback text explains main-chat constraint", () => { + const reply = buildNewCommandRejectionReply(); + expect(reply.text).toContain("主群"); + expect(reply.text).toContain("话题"); + }); + + test("card carries elements beyond just the title", () => { + const reply = buildNewCommandRejectionReply(); + expect(reply.card.body.elements.length).toBeGreaterThan(1); + }); +}); + +describe("buildNewCommandUsageReply", () => { + test("fallback text shows usage hint", () => { + const reply = buildNewCommandUsageReply(); + expect(reply.text).toContain("/new"); + }); + + test("card body is populated", () => { + const reply = buildNewCommandUsageReply(); + expect(reply.card.body.elements.length).toBeGreaterThan(1); + }); +}); diff --git a/tests/kernel/commands/parser.test.ts b/tests/kernel/commands/parser.test.ts new file mode 100644 index 0000000..87ca0ca --- /dev/null +++ b/tests/kernel/commands/parser.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; + +import { parseCommand } from "@/kernel/commands"; + +describe("parseCommand", () => { + test("parses a bare command", () => { + expect(parseCommand("/ls")).toEqual({ + name: "ls", + args: [], + raw: "/ls", + }); + }); + + test("parses a command with args", () => { + expect(parseCommand("/bind ws_123")).toEqual({ + name: "bind", + args: ["ws_123"], + raw: "/bind ws_123", + }); + }); + + test("lowercases the command name but preserves arg case", () => { + expect(parseCommand("/STATUS WS_123")).toMatchObject({ + name: "status", + args: ["WS_123"], + }); + }); + + test("splits args on whitespace runs", () => { + expect(parseCommand("/clone url alias")).toMatchObject({ + args: ["url", "alias"], + }); + }); + + test("trims outer whitespace", () => { + expect(parseCommand(" /ls ")).toMatchObject({ name: "ls" }); + }); + + test("returns null for non-slash input", () => { + expect(parseCommand("hello")).toBeNull(); + expect(parseCommand("")).toBeNull(); + expect(parseCommand(" ")).toBeNull(); + }); + + test("returns null for bare slash", () => { + expect(parseCommand("/")).toBeNull(); + expect(parseCommand(" / ")).toBeNull(); + }); +}); diff --git a/tests/kernel/commands/unknown-command.test.ts b/tests/kernel/commands/unknown-command.test.ts new file mode 100644 index 0000000..40d4b10 --- /dev/null +++ b/tests/kernel/commands/unknown-command.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { buildUnknownCommandReply } from "@/kernel/commands"; + +describe("buildUnknownCommandReply", () => { + test("names the offending command when parseable", () => { + const reply = buildUnknownCommandReply("/halp"); + expect(reply.text).toContain("/halp"); + expect(reply.text).toContain("/help"); + }); + + test("names the command even when args follow", () => { + const reply = buildUnknownCommandReply("/foo bar baz"); + expect(reply.text).toContain("/foo"); + }); + + test("lowercases command name in the message", () => { + const reply = buildUnknownCommandReply("/FOO"); + expect(reply.text).toContain("/foo"); + }); + + test("falls back to a format-error message for bare slash", () => { + const reply = buildUnknownCommandReply("/"); + expect(reply.text).toContain("格式"); + expect(reply.text).not.toContain("`/`"); + }); + + test("card body is populated", () => { + const reply = buildUnknownCommandReply("/whatever"); + expect(reply.card.body.elements.length).toBeGreaterThan(1); + }); + + test("always points users to /help", () => { + expect(buildUnknownCommandReply("/foo").text).toContain("/help"); + expect(buildUnknownCommandReply("/").text).toContain("/help"); + }); +}); From 3c4c3c61c0e835f7cebb368708eec8a233e3e28a Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 18:23:23 +0800 Subject: [PATCH 38/69] feat(permission): interactive tool-approval via Feishu cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge Claude Code's `--permission-prompt-tool` mechanism into the Feishu chat so the initiator can approve or deny each tool call before it runs. Request path: - Claude CLI spawns a stdio MCP subprocess (src/community/anthropic/ permission-mcp-server.ts) whenever it needs approval to run a tool. - The subprocess posts the tool name/input to the kernel's internal Hono endpoint `/internal/permission/request` (bearer-token auth against a per-boot rotating secret, not mounted under /api, localhost only). - The route long-polls `kernel.permissionFlow.request(...)` and returns the decision in Claude's expected `{behavior, updatedInput}` / `{behavior, message}` shape. Orchestration (src/kernel/permission/): - `PermissionFlow.request()` renders an approve/deny card keyed by the outbound message_id and returns a Promise that resolves on card-click or a 5-minute auto-deny timeout. - `handleDecide()` validates the operator (only the original initiator can decide) and replaces the card with a terminal result card. Single-writer semantics — pending entry is deleted before resolve to prevent double-resolution from duplicate clicks or a click racing the timeout. - API token is a per-boot `uuid()` compared in constant time; not persisted, not reachable from the public internet. Wiring: - Kernel inits `PermissionFlow` and publishes `AGENTARA_PERMISSION_URL` + `AGENTARA_PERMISSION_TOKEN` to `process.env` after Hono binds, so src/community/* can pick them up without reversing the dependency direction. - ClaudeAgentRunner detects (chat_id, channel_id, sender_open_id) + published env and writes a tmp `.mcp.json` pointing at the stdio server, passing `--mcp-config` and `--permission-prompt-tool` to the CLI. Tmp dir is cleaned up on run end. Falls back to current behavior when context is missing (scheduled tasks, non-Feishu sources) so those paths aren't broken. - Card-action router dispatches `permission_decide` payloads to the flow. Unit tests cover: allow, non-initiator rejection with later valid decide, auto-deny on timeout, and constant-time token verification. --- .../anthropic/claude-agent-runner.ts | 98 +++++- .../anthropic/permission-mcp-server.ts | 241 ++++++++++++++ src/kernel/kernel.ts | 36 ++ src/kernel/permission/index.ts | 11 + src/kernel/permission/permission-card.ts | 182 +++++++++++ src/kernel/permission/permission-flow.ts | 308 ++++++++++++++++++ src/server/routes/index.ts | 1 + src/server/routes/permission.ts | 63 ++++ src/server/server.ts | 5 + .../kernel/permission/permission-flow.test.ts | 147 +++++++++ 10 files changed, 1089 insertions(+), 3 deletions(-) create mode 100644 src/community/anthropic/permission-mcp-server.ts create mode 100644 src/kernel/permission/index.ts create mode 100644 src/kernel/permission/permission-card.ts create mode 100644 src/kernel/permission/permission-flow.ts create mode 100644 src/server/routes/permission.ts create mode 100644 tests/kernel/permission/permission-flow.test.ts diff --git a/src/community/anthropic/claude-agent-runner.ts b/src/community/anthropic/claude-agent-runner.ts index 95a75f0..261a501 100644 --- a/src/community/anthropic/claude-agent-runner.ts +++ b/src/community/anthropic/claude-agent-runner.ts @@ -1,3 +1,9 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + import { config, createLogger, @@ -51,10 +57,25 @@ export class ClaudeAgentRunner implements AgentRunner { ? ["--dangerously-skip-permissions"] : []), ...["--output-format", "stream-json"], - "--print", - "--verbose", - textContentOfUserMessage, ]; + + // Wire up the interactive permission MCP bridge when the inbound + // message carries a known Feishu user (chat_id + channel_id + + // sender_open_id) and the kernel has published its internal + // approval endpoint into the env. Otherwise fall back to Claude's + // default behavior (non-interactive, auto-deny for unapproved + // tools) so scheduled-task and non-Feishu paths aren't broken. + const permissionBridge = _buildPermissionBridge(message, options); + if (permissionBridge) { + args.push( + "--mcp-config", + permissionBridge.mcpConfigPath, + "--permission-prompt-tool", + "mcp__agentara__approve_tool_use", + ); + } + + args.push("--print", "--verbose", textContentOfUserMessage); const proc = Bun.spawn(args, { cwd: options.cwd, env: { @@ -121,6 +142,7 @@ export class ClaudeAgentRunner implements AgentRunner { if (signal) { signal.removeEventListener("abort", abortHandler); } + permissionBridge?.cleanup(); } if (aborted) { @@ -185,3 +207,73 @@ export class ClaudeAgentRunner implements AgentRunner { function containsToolResult(message: { content: MessageContent[] }): boolean { return message.content.some((content) => content.type === "tool_result"); } + +interface PermissionBridge { + mcpConfigPath: string; + cleanup: () => void; +} + +function _buildPermissionBridge( + message: UserMessage, + options: AgentRunOptions, +): PermissionBridge | null { + if (options.dangerouslySkipPermissions) return null; + const chatId = message.chat_id; + const channelId = message.channel_id; + const initiatorOpenId = message.sender_open_id; + const approvalUrl = Bun.env.AGENTARA_PERMISSION_URL; + const approvalToken = Bun.env.AGENTARA_PERMISSION_TOKEN; + if (!chatId || !channelId || !initiatorOpenId) return null; + if (!approvalUrl || !approvalToken) return null; + + const scriptPath = _resolveMcpScriptPath(); + const mcpConfig = { + mcpServers: { + agentara: { + command: "bun", + args: ["run", scriptPath], + env: { + AGENTARA_APPROVAL_URL: approvalUrl, + AGENTARA_APPROVAL_TOKEN: approvalToken, + AGENTARA_SESSION_ID: message.session_id, + AGENTARA_CHANNEL_ID: channelId, + AGENTARA_CHAT_ID: chatId, + AGENTARA_INITIATOR_OPEN_ID: initiatorOpenId, + AGENTARA_REPLY_TO_MESSAGE_ID: message.id ?? "", + }, + }, + }, + }; + const dir = mkdtempSync(join(tmpdir(), "agentara-claude-mcp-")); + const mcpConfigPath = join(dir, `mcp-${randomUUID()}.json`); + writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig)); + return { + mcpConfigPath, + cleanup: () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (err) { + logger.warn( + { err, dir }, + "failed to clean up temp mcp-config dir", + ); + } + }, + }; +} + +/** + * Absolute path to the stdio MCP server script. Resolved relative to + * this file so it works both under `bun --watch run index.ts` (dev) + * and from a bundled JS build where `import.meta.url` still points + * inside the output dir. + * + * When shipping a `bun --compile` binary the .ts source won't exist + * at that path at runtime — the caller would need to either keep the + * source alongside the binary or switch to an inlined-string strategy. + * Leaving a clear breadcrumb rather than silently failing. + */ +function _resolveMcpScriptPath(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "permission-mcp-server.ts"); +} diff --git a/src/community/anthropic/permission-mcp-server.ts b/src/community/anthropic/permission-mcp-server.ts new file mode 100644 index 0000000..efd6b40 --- /dev/null +++ b/src/community/anthropic/permission-mcp-server.ts @@ -0,0 +1,241 @@ +/** + * Standalone stdio MCP server spawned by Claude Code as the + * `--permission-prompt-tool` backend. + * + * The Claude CLI invokes `approve_tool_use` before running any tool + * that would normally need interactive approval. This server: + * + * 1. Receives the JSON-RPC `tools/call` request on stdin. + * 2. Forwards it (plus the per-session context baked into the spawn + * env) to the kernel's `/internal/permission/request` endpoint. + * 3. Blocks on the kernel's long-poll response (the kernel sends an + * approve/deny card to Feishu and waits up to 5 min). + * 4. Returns the decision as a single `text` content block in the + * shape Claude expects: + * `{"behavior":"allow","updatedInput":{…}}` + * or + * `{"behavior":"deny","message":"…"}`. + * + * This file is intentionally self-contained — it runs in its own + * subprocess launched by Claude, not inside the kernel's runtime. + * Do NOT import from `@/...` here; the import alias is not resolved + * for ad-hoc `bun run ` subprocess invocations spawned via + * `.mcp.json`. + */ + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: number | string | null; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number | string | null; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +const PROTOCOL_VERSION = "2024-11-05"; +const TOOL_NAME = "approve_tool_use"; + +function _env(key: string, required = true): string { + const v = process.env[key]; + if ((!v || v.length === 0) && required) { + _writeToStderr(`[permission-mcp] missing required env: ${key}`); + process.exit(1); + } + return v ?? ""; +} + +function _writeToStderr(line: string): void { + try { + process.stderr.write(line + "\n"); + } catch { + // ignore + } +} + +function _writeResponse(resp: JsonRpcResponse): void { + process.stdout.write(JSON.stringify(resp) + "\n"); +} + +function _buildInitializeResult(): Record { + return { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + tools: {}, + }, + serverInfo: { + name: "agentara-permission", + version: "0.1.0", + }, + }; +} + +function _buildToolsListResult(): Record { + return { + tools: [ + { + name: TOOL_NAME, + description: + "Approve or deny a Claude Code tool use by forwarding the request " + + "to the agentara kernel, which asks the human via a Feishu card.", + inputSchema: { + type: "object", + properties: { + tool_name: { type: "string" }, + input: { type: "object", additionalProperties: true }, + tool_use_id: { type: "string" }, + }, + required: ["tool_name", "input"], + additionalProperties: true, + }, + }, + ], + }; +} + +async function _handleToolsCall( + args: Record, +): Promise> { + const toolName = + typeof args.tool_name === "string" ? args.tool_name : "(unknown)"; + const toolInput = + args.input && typeof args.input === "object" + ? (args.input as Record) + : {}; + + const approvalUrl = _env("AGENTARA_APPROVAL_URL"); + const approvalToken = _env("AGENTARA_APPROVAL_TOKEN"); + const body = { + session_id: _env("AGENTARA_SESSION_ID"), + channel_id: _env("AGENTARA_CHANNEL_ID"), + chat_id: _env("AGENTARA_CHAT_ID"), + initiator_open_id: _env("AGENTARA_INITIATOR_OPEN_ID"), + reply_to_message_id: _env("AGENTARA_REPLY_TO_MESSAGE_ID", false) || undefined, + tool_name: toolName, + tool_input: toolInput, + }; + + let decisionText: string; + try { + const res = await fetch(approvalUrl, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${approvalToken}`, + }, + body: JSON.stringify(body), + // Bun's fetch supports no default timeout; the kernel already + // caps the wait at 5 min + small buffer, and the subprocess is + // single-purpose so a stuck request just blocks this tool call. + }); + if (!res.ok) { + throw new Error(`kernel returned HTTP ${res.status}`); + } + // We pass the kernel's JSON body through verbatim as the Claude + // permission tool's text content — the shape already matches + // `{behavior, updatedInput|message}`. + decisionText = await res.text(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + _writeToStderr(`[permission-mcp] kernel call failed: ${message}`); + decisionText = JSON.stringify({ + behavior: "deny", + message: `Permission bridge failed: ${message}`, + }); + } + + return { + content: [{ type: "text", text: decisionText }], + }; +} + +async function _handleRequest(req: JsonRpcRequest): Promise { + const id = req.id ?? null; + try { + switch (req.method) { + case "initialize": + _writeResponse({ jsonrpc: "2.0", id, result: _buildInitializeResult() }); + return; + case "notifications/initialized": + // Notifications have no id and no response. + return; + case "tools/list": + _writeResponse({ jsonrpc: "2.0", id, result: _buildToolsListResult() }); + return; + case "tools/call": { + const params = (req.params ?? {}) as { + name?: string; + arguments?: Record; + }; + if (params.name !== TOOL_NAME) { + _writeResponse({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `unknown tool: ${String(params.name)}`, + }, + }); + return; + } + const result = await _handleToolsCall(params.arguments ?? {}); + _writeResponse({ jsonrpc: "2.0", id, result }); + return; + } + case "ping": + _writeResponse({ jsonrpc: "2.0", id, result: {} }); + return; + default: + if (req.id !== undefined && req.id !== null) { + _writeResponse({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `method not found: ${req.method}` }, + }); + } + return; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + _writeToStderr(`[permission-mcp] handler error: ${message}`); + if (req.id !== undefined && req.id !== null) { + _writeResponse({ + jsonrpc: "2.0", + id, + error: { code: -32603, message }, + }); + } + } +} + +async function _main(): Promise { + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of process.stdin as unknown as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + let parsed: JsonRpcRequest; + try { + parsed = JSON.parse(line) as JsonRpcRequest; + } catch { + _writeToStderr(`[permission-mcp] malformed line: ${line.slice(0, 200)}`); + continue; + } + await _handleRequest(parsed); + } + } +} + +void _main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + _writeToStderr(`[permission-mcp] fatal: ${message}`); + process.exit(1); +}); diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 6250d01..9d3ed96 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -32,6 +32,7 @@ import { import { buildCommandCard } from "./commands/cards"; import { GroupFlow } from "./group/group-flow"; import { MultiChannelMessageGateway } from "./messaging"; +import { PERMISSION_ACTION, PermissionFlow } from "./permission"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; import { SetupFlow } from "./setup/setup-flow"; @@ -57,6 +58,7 @@ class Kernel { private _setupFlow!: SetupFlow; private _switchFlow!: SwitchFlow; private _groupFlow!: GroupFlow; + private _permissionFlow!: PermissionFlow; constructor() { this._initDatabase(); @@ -68,6 +70,7 @@ class Kernel { this._initSetupFlow(); this._initSwitchFlow(); this._initGroupFlow(); + this._initPermissionFlow(); this._initServer(); } @@ -87,6 +90,10 @@ class Kernel { return this._honoServer; } + get permissionFlow(): PermissionFlow { + return this._permissionFlow; + } + private _initDatabase(): void { this._database = new DataConnection({ ...taskingSchema, @@ -185,6 +192,12 @@ class Kernel { }); } + private _initPermissionFlow(): void { + this._permissionFlow = new PermissionFlow({ + feishuChannels: this._feishuChannels, + }); + } + /** * Start the kernel. */ @@ -192,9 +205,28 @@ class Kernel { await this._sessionManager.start(); await this._taskDispatcher.start(); await this._honoServer.start(); + this._publishPermissionEndpointEnv(); await this._messageGateway.start(); } + /** + * Expose the internal approval endpoint + per-boot token via + * `process.env` so runners in `src/community/*` can pick them up + * without importing from `@/kernel` (which would reverse the + * dependency direction). The MCP subprocess spawned by Claude + * reads these at spawn time; the host only advertises them after + * Hono has bound its port. + */ + private _publishPermissionEndpointEnv(): void { + const port = parseInt(Bun.env.AGENTARA_SERVICE_PORT ?? "1984", 10); + // Force localhost — the endpoint is not internet-reachable even + // if Hono binds to 0.0.0.0, because the bearer token rotates per + // boot and isn't persisted. + const url = `http://127.0.0.1:${port}/internal/permission/request`; + process.env.AGENTARA_PERMISSION_URL = url; + process.env.AGENTARA_PERMISSION_TOKEN = this._permissionFlow.apiToken; + } + private _handleInboundMessage = async (message: UserMessage) => { // Feishu substitutes @mentions as `@_user_N` placeholders. Two-tier // stripping: @@ -505,6 +537,10 @@ class Kernel { await this._switchFlow.handleSubmit(payload); return; } + if (payload.action_name === PERMISSION_ACTION) { + await this._permissionFlow.handleDecide(payload); + return; + } this._logger.warn( { action_name: payload.action_name, message_id: payload.message_id }, "unhandled card action", diff --git a/src/kernel/permission/index.ts b/src/kernel/permission/index.ts new file mode 100644 index 0000000..ac73e11 --- /dev/null +++ b/src/kernel/permission/index.ts @@ -0,0 +1,11 @@ +export { + buildPermissionCard, + buildPermissionResultCard, + PERMISSION_ACTION, + type PermissionCallbackValue, +} from "./permission-card"; +export { + PermissionFlow, + type PermissionDecision, + type PermissionRequestParams, +} from "./permission-flow"; diff --git a/src/kernel/permission/permission-card.ts b/src/kernel/permission/permission-card.ts new file mode 100644 index 0000000..1c4ddf0 --- /dev/null +++ b/src/kernel/permission/permission-card.ts @@ -0,0 +1,182 @@ +import type { + ButtonElement, + Card, + ColumnSetElement, + Element, +} from "../../community/feishu/messaging/types"; +import { buildCardIntro, buildMarkdown, buildResultCard } from "../setup/card-ui"; + +/** + * `action` discriminator echoed back on `card.action.trigger` when the + * user clicks Approve or Deny. Matched by {@link PermissionFlow}. + */ +export const PERMISSION_ACTION = "permission_decide"; + +/** + * Payload attached to each permission button's callback. The decision is + * the click result; `request_id` correlates with the pending map in + * {@link PermissionFlow}. + * + * The index signature is there only to satisfy the `CallbackValue = + * Record` contract on {@link CallbackBehavior}; the + * real shape is the three named fields above it. + */ +export interface PermissionCallbackValue { + action: typeof PERMISSION_ACTION; + request_id: string; + decision: "allow" | "deny"; + [key: string]: unknown; +} + +/** + * Build the permission-request card shown to the initiator. The body + * stays intentionally flat: a short summary line + a collapsible JSON + * block for the raw input, so scanability wins for simple tools while + * power users can still inspect the exact args. + */ +export function buildPermissionCard(options: { + request_id: string; + tool_name: string; + tool_input: unknown; + initiator_open_id: string; +}): Card { + const preview = _formatInputPreview(options.tool_input); + const elements: Element[] = [ + buildCardIntro({ + title: "🔒 权限请求", + subtitle: `Claude Code 请求调用工具 \`${options.tool_name}\``, + }), + buildMarkdown( + [ + `- 发起人:`, + `- 工具:\`${options.tool_name}\``, + ].join("\n"), + ), + ]; + + if (preview) { + elements.push( + buildMarkdown("调用参数", { + text_size: "notation", + }), + buildMarkdown("```json\n" + preview + "\n```"), + ); + } + + elements.push(_buildButtonRow(options.request_id)); + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { + content: `🔒 权限请求:${options.tool_name}`, + }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +/** + * Terminal card shown after a decision is made (approve / deny / timeout + * / wrong operator / already-decided). Replaces the original card in + * place. + */ +export function buildPermissionResultCard(options: { + tool_name: string; + outcome: + | "allowed" + | "denied" + | "timeout" + | "wrong_operator" + | "already_decided" + | "expired"; + decided_by_open_id?: string; +}): Card { + const { tool_name, outcome } = options; + const decided_by = options.decided_by_open_id; + let summary: string; + switch (outcome) { + case "allowed": + summary = decided_by + ? `✅ 已批准 \`${tool_name}\`。` + : `✅ 已批准 \`${tool_name}\`。`; + break; + case "denied": + summary = decided_by + ? `🚫 已拒绝 \`${tool_name}\`。` + : `🚫 已拒绝 \`${tool_name}\`。`; + break; + case "timeout": + summary = `⚠️ 5 分钟内未响应,已按拒绝处理 \`${tool_name}\`。`; + break; + case "wrong_operator": + summary = "🚫 这不是你的权限卡片,只有发起人可以决定。"; + break; + case "already_decided": + summary = "ℹ️ 该权限请求已经处理过。"; + break; + case "expired": + summary = "⚠️ 该权限请求已失效。"; + break; + } + return buildResultCard({ + title: "权限请求", + summary, + }); +} + +function _formatInputPreview(input: unknown): string { + if (input === undefined || input === null) return ""; + try { + const text = JSON.stringify(input, null, 2); + if (text.length <= 1200) return text; + return text.slice(0, 1200) + "\n… (truncated)"; + } catch { + return String(input); + } +} + +function _buildButtonRow(requestId: string): ColumnSetElement { + const allowValue: PermissionCallbackValue = { + action: PERMISSION_ACTION, + request_id: requestId, + decision: "allow", + }; + const denyValue: PermissionCallbackValue = { + action: PERMISSION_ACTION, + request_id: requestId, + decision: "deny", + }; + const approveBtn: ButtonElement = { + tag: "button", + name: "permission_allow", + text: { tag: "plain_text", content: "✅ 批准" }, + type: "primary", + width: "fill", + behaviors: [{ type: "callback", value: allowValue }], + }; + const denyBtn: ButtonElement = { + tag: "button", + name: "permission_deny", + text: { tag: "plain_text", content: "🚫 拒绝" }, + type: "danger", + width: "fill", + behaviors: [{ type: "callback", value: denyValue }], + }; + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [approveBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [denyBtn] }, + ], + }; +} diff --git a/src/kernel/permission/permission-flow.ts b/src/kernel/permission/permission-flow.ts new file mode 100644 index 0000000..86d657f --- /dev/null +++ b/src/kernel/permission/permission-flow.ts @@ -0,0 +1,308 @@ +import { createLogger, uuid, type CardActionPayload, type Logger } from "@/shared"; + +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; + +import { + buildPermissionCard, + buildPermissionResultCard, + PERMISSION_ACTION, + type PermissionCallbackValue, +} from "./permission-card"; + +/** + * Outcome returned by {@link PermissionFlow.request}. Shape mirrors + * Claude Code's `--permission-prompt-tool` contract so the MCP bridge + * can pass it through almost verbatim. + */ +export interface PermissionDecision { + behavior: "allow" | "deny"; + /** + * Required by Claude on allow: the (possibly modified) tool input to + * actually run. We echo back the original input unchanged — we never + * mutate tool calls on the user's behalf. + */ + updated_input?: Record; + /** Optional deny reason shown to the model. */ + message?: string; + decided_by: "user" | "timeout"; +} + +/** + * Parameters for a single permission-request round-trip. Everything + * here is routed into the card (for display) or the pending registry + * (for dispatch/validation). + */ +export interface PermissionRequestParams { + session_id: string; + tool_name: string; + tool_input: Record; + channel_id: string; + chat_id: string; + initiator_open_id: string; + /** Optional message id to anchor the card under (keeps reply chain intact). */ + reply_to_message_id?: string; +} + +/** Default auto-deny window when the initiator never clicks. */ +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +interface PendingEntry { + request_id: string; + session_id: string; + tool_name: string; + initiator_open_id: string; + channel_id: string; + chat_id: string; + card_message_id: string; + created_at: number; + // eslint-disable-next-line no-unused-vars + resolve: (decision: PermissionDecision) => void; + timeout: ReturnType; +} + +/** + * Orchestrates one interactive permission round-trip per + * Claude-Code tool call: + * + * 1. `request(params)` renders an approve/deny card into the chat, + * stores a pending entry keyed by the outbound card's `message_id`, + * and returns a Promise that resolves when the user clicks or the + * timeout fires. + * 2. The kernel routes `card:action` events with + * `value.action === "permission_decide"` to `handleDecide(payload)`, + * which validates the operator, resolves the pending promise, and + * replaces the card with a terminal result card. + * + * Single-writer per request: we delete the pending entry before + * resolving, so duplicate clicks (or a timeout that races with a late + * click) don't double-resolve the promise. + */ +export class PermissionFlow { + private readonly _logger: Logger = createLogger("permission-flow"); + private readonly _feishuChannels: Map; + private readonly _pending = new Map(); + private readonly _timeoutMs: number; + + /** + * Process-lifetime shared secret used by the MCP stdio subprocess to + * call back into the kernel's internal permission endpoint. Rotated + * on every kernel boot; not persisted. Only reachable from localhost. + */ + private readonly _apiToken: string = uuid(); + + constructor(deps: { + feishuChannels: Map; + timeoutMs?: number; + }) { + this._feishuChannels = deps.feishuChannels; + this._timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + /** + * Token the MCP subprocess must send in `Authorization: Bearer …` when + * calling the kernel's internal permission endpoint. Threaded into + * subprocess env at spawn time; never logged. + */ + get apiToken(): string { + return this._apiToken; + } + + /** Constant-time token comparison so unauthorized callers can't time us. */ + verifyToken(candidate: string | undefined | null): boolean { + if (!candidate) return false; + if (candidate.length !== this._apiToken.length) return false; + let diff = 0; + for (let i = 0; i < candidate.length; i++) { + diff |= candidate.charCodeAt(i) ^ this._apiToken.charCodeAt(i); + } + return diff === 0; + } + + /** + * Send a permission card to the initiator's chat and await a decision. + * + * Rejects only when we fail to send the card at all (no channel, API + * error) — the caller should treat that as a deny so the tool call + * doesn't silently stall. + */ + async request(params: PermissionRequestParams): Promise { + const channel = this._feishuChannels.get(params.channel_id); + if (!channel) { + throw new Error( + `Permission request for unknown channel_id=${params.channel_id}`, + ); + } + const requestId = uuid(); + const card = buildPermissionCard({ + request_id: requestId, + tool_name: params.tool_name, + tool_input: params.tool_input, + initiator_open_id: params.initiator_open_id, + }); + + const cardMessageId = await channel.sendRawCard(params.chat_id, card, { + replyTo: params.reply_to_message_id, + // Keep the card inline with the original turn; threading would hide + // it from anyone not already following the topic. + replyInThread: false, + }); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + const entry = this._pending.get(cardMessageId); + if (!entry) return; + this._pending.delete(cardMessageId); + this._logger.warn( + { + request_id: requestId, + session_id: params.session_id, + tool_name: params.tool_name, + }, + "permission request timed out, auto-denying", + ); + void this._tryUpdateCard( + params.channel_id, + cardMessageId, + buildPermissionResultCard({ + tool_name: params.tool_name, + outcome: "timeout", + }), + "timeout", + ); + resolve({ + behavior: "deny", + message: "Permission request timed out after 5 minutes.", + decided_by: "timeout", + }); + }, this._timeoutMs); + + this._pending.set(cardMessageId, { + request_id: requestId, + session_id: params.session_id, + tool_name: params.tool_name, + initiator_open_id: params.initiator_open_id, + channel_id: params.channel_id, + chat_id: params.chat_id, + card_message_id: cardMessageId, + created_at: Date.now(), + resolve, + timeout, + }); + this._logger.info( + { + request_id: requestId, + session_id: params.session_id, + tool_name: params.tool_name, + card_message_id: cardMessageId, + }, + "permission card sent", + ); + }); + } + + /** + * Entry point invoked from the kernel's `card:action` listener when the + * payload's `value.action === PERMISSION_ACTION`. Looks up the pending + * entry, validates the operator, and either resolves the Promise with + * the clicked decision or surfaces an error card. + */ + async handleDecide(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id }, + "permission action for unknown channel", + ); + return; + } + const entry = this._pending.get(payload.message_id); + if (!entry) { + await this._tryUpdateCard( + payload.channel_id, + payload.message_id, + buildPermissionResultCard({ + tool_name: "(unknown)", + outcome: "already_decided", + }), + "already-decided", + ); + return; + } + if (entry.initiator_open_id !== payload.operator_open_id) { + // Don't consume the pending entry — the initiator can still click. + // We don't mutate the card either: rewriting buttons on the fly + // races with the pending promise and risks losing them on failure. + // The clicker sees Feishu's generic ack toast; the card stays + // intact for the initiator to act on. + this._logger.info( + { + request_id: entry.request_id, + operator: payload.operator_open_id, + initiator: entry.initiator_open_id, + }, + "non-initiator click on permission card; ignoring", + ); + return; + } + + const value = payload.value as unknown as PermissionCallbackValue; + const decision: "allow" | "deny" = + value?.decision === "allow" ? "allow" : "deny"; + + this._pending.delete(payload.message_id); + clearTimeout(entry.timeout); + + await this._tryUpdateCard( + payload.channel_id, + payload.message_id, + buildPermissionResultCard({ + tool_name: entry.tool_name, + outcome: decision === "allow" ? "allowed" : "denied", + decided_by_open_id: payload.operator_open_id, + }), + "final-result", + ); + + this._logger.info( + { + request_id: entry.request_id, + session_id: entry.session_id, + tool_name: entry.tool_name, + decision, + }, + "permission request decided", + ); + + entry.resolve({ + behavior: decision, + message: + decision === "deny" ? "Permission denied by the user." : undefined, + decided_by: "user", + }); + } + + /** + * Tag for the {@link PermissionFlow._handleCardAction} discriminator — + * exported so the kernel-level router can filter by `value.action` + * without re-deriving the string constant. + */ + static readonly ACTION_NAME = PERMISSION_ACTION; + + private async _tryUpdateCard( + channelId: string, + messageId: string, + card: ReturnType, + stage: string, + ): Promise { + const channel = this._feishuChannels.get(channelId); + if (!channel) return; + try { + await channel.updateRawCard(messageId, card); + } catch (err) { + this._logger.error( + { err, stage, message_id: messageId }, + "permission updateRawCard failed", + ); + } + } +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index d38c893..ba496db 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,6 +1,7 @@ export { cronjobsRoutes } from "./cronjobs"; export { healthRoutes } from "./health"; export { memoryRoutes } from "./memory"; +export { permissionRoutes } from "./permission"; export { sessionRoutes } from "./sessions"; export { skillsRoutes } from "./skills"; export { taskRoutes } from "./tasks"; diff --git a/src/server/routes/permission.ts b/src/server/routes/permission.ts new file mode 100644 index 0000000..9d64278 --- /dev/null +++ b/src/server/routes/permission.ts @@ -0,0 +1,63 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +import { kernel } from "@/kernel"; +import { createLogger } from "@/shared"; + +const _logger = createLogger("permission-routes"); + +/** + * Request body posted by the MCP stdio subprocess when Claude Code + * invokes the `approve_tool_use` tool. The subprocess forwards the + * Claude-side payload plus the per-session context that was baked into + * its spawn env, so the kernel can route the card to the right chat. + */ +const PermissionRequestBody = z.object({ + session_id: z.string(), + channel_id: z.string(), + chat_id: z.string(), + initiator_open_id: z.string(), + reply_to_message_id: z.string().optional(), + tool_name: z.string(), + tool_input: z.record(z.string(), z.unknown()), +}); + +/** + * Internal-only endpoint the MCP permission subprocess long-polls + * against. Not mounted under `/api` because it isn't part of the + * public surface; bearer-token auth against {@link PermissionFlow.apiToken} + * is the only gate. + */ +export const permissionRoutes = new Hono().post( + "/request", + zValidator("json", PermissionRequestBody), + async (c) => { + const authz = c.req.header("Authorization") ?? ""; + const token = authz.startsWith("Bearer ") ? authz.slice(7) : ""; + if (!kernel.permissionFlow.verifyToken(token)) { + _logger.warn("unauthorized permission request"); + return c.json({ error: "unauthorized" }, 401); + } + const body = c.req.valid("json"); + const decision = await kernel.permissionFlow.request({ + session_id: body.session_id, + channel_id: body.channel_id, + chat_id: body.chat_id, + initiator_open_id: body.initiator_open_id, + reply_to_message_id: body.reply_to_message_id, + tool_name: body.tool_name, + tool_input: body.tool_input, + }); + if (decision.behavior === "allow") { + return c.json({ + behavior: "allow", + updatedInput: decision.updated_input ?? body.tool_input, + }); + } + return c.json({ + behavior: "deny", + message: decision.message ?? "Permission denied", + }); + }, +); diff --git a/src/server/server.ts b/src/server/server.ts index 90ed345..c113eb1 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -13,6 +13,7 @@ import { cronjobsRoutes, healthRoutes, memoryRoutes, + permissionRoutes, sessionRoutes, skillsRoutes, taskRoutes, @@ -39,6 +40,10 @@ function createApp() { .route("/api/skills", skillsRoutes) .route("/api/tasks", taskRoutes) .route("/api/usage", usageRoutes) + // Internal-only route used by the Claude permission MCP subprocess. + // Auth is bearer-token against a rotating in-memory secret (see + // PermissionFlow.apiToken). Keep off `/api` so it doesn't get CORS. + .route("/internal/permission", permissionRoutes) ); } diff --git a/tests/kernel/permission/permission-flow.test.ts b/tests/kernel/permission/permission-flow.test.ts new file mode 100644 index 0000000..05e122c --- /dev/null +++ b/tests/kernel/permission/permission-flow.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; + + +import type { FeishuMessageChannel } from "@/community/feishu/messaging/message-channel"; +import type { Card } from "@/community/feishu/messaging/types"; +import { PermissionFlow } from "@/kernel/permission"; +import type { CardActionPayload } from "@/shared"; + +interface FakeChannel { + sentCards: Array<{ chatId: string; card: Card }>; + updatedCards: Array<{ messageId: string; card: Card }>; +} + +function _makeChannel(messageId: string): { + fake: FakeChannel; + channel: FeishuMessageChannel; +} { + const fake: FakeChannel = { sentCards: [], updatedCards: [] }; + const channel = { + async sendRawCard(chatId: string, card: Card) { + fake.sentCards.push({ chatId, card }); + return messageId; + }, + async updateRawCard(mid: string, card: Card) { + fake.updatedCards.push({ messageId: mid, card }); + }, + } as unknown as FeishuMessageChannel; + return { fake, channel }; +} + +function _makePayload(overrides: Partial): CardActionPayload { + return { + message_id: overrides.message_id ?? "card_msg_1", + channel_id: overrides.channel_id ?? "ch_1", + chat_id: overrides.chat_id ?? "oc_chat", + operator_open_id: overrides.operator_open_id ?? "ou_alice", + action_name: overrides.action_name ?? "permission_decide", + value: overrides.value ?? {}, + form_value: overrides.form_value ?? {}, + }; +} + +describe("PermissionFlow", () => { + test("resolves 'allow' when the initiator clicks Approve", async () => { + const { channel } = _makeChannel("card_msg_1"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + const promise = flow.request({ + session_id: "s1", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: { command: "ls" }, + }); + // Let the send-card await resolve before we fire the click. + await Promise.resolve(); + await flow.handleDecide( + _makePayload({ + message_id: "card_msg_1", + operator_open_id: "ou_alice", + value: { + action: "permission_decide", + request_id: "whatever", + decision: "allow", + }, + }), + ); + const decision = await promise; + expect(decision.behavior).toBe("allow"); + expect(decision.decided_by).toBe("user"); + }); + + test("ignores non-initiator clicks; initiator can still decide", async () => { + const { channel } = _makeChannel("card_msg_2"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + const promise = flow.request({ + session_id: "s1", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: {}, + }); + await Promise.resolve(); + + await flow.handleDecide( + _makePayload({ + message_id: "card_msg_2", + operator_open_id: "ou_mallory", + value: { + action: "permission_decide", + request_id: "x", + decision: "allow", + }, + }), + ); + // Pending must still resolve on the real initiator's click. + await flow.handleDecide( + _makePayload({ + message_id: "card_msg_2", + operator_open_id: "ou_alice", + value: { + action: "permission_decide", + request_id: "x", + decision: "deny", + }, + }), + ); + const decision = await promise; + expect(decision.behavior).toBe("deny"); + expect(decision.decided_by).toBe("user"); + }); + + test("auto-denies after the timeout fires", async () => { + const { channel } = _makeChannel("card_msg_3"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + timeoutMs: 30, + }); + const decision = await flow.request({ + session_id: "s1", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: {}, + }); + expect(decision.behavior).toBe("deny"); + expect(decision.decided_by).toBe("timeout"); + }); + + test("verifyToken is constant-time and rejects bad tokens", () => { + const { channel } = _makeChannel("card_msg_4"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + expect(flow.verifyToken(flow.apiToken)).toBe(true); + expect(flow.verifyToken("")).toBe(false); + expect(flow.verifyToken(flow.apiToken + "x")).toBe(false); + expect(flow.verifyToken(null)).toBe(false); + expect(flow.verifyToken(undefined)).toBe(false); + }); +}); From d75e10594d9f8b29e12621dc6311fab2dde133aa Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 22 Apr 2026 21:01:39 +0800 Subject: [PATCH 39/69] feat(workspaces): share git objects across clones via --reference cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new workspace previously paid the full clone cost of each predefined repo, even when the same repo already lived in other workspaces under `$AGENTARA_HOME`. Introduce an agentara-managed object cache that lets all workspaces share history via git's `--reference` mechanism. Layout: - `$AGENTARA_HOME/git-cache/.git/` — one bare mirror per predefined repo (created with `git clone --mirror`). Never used as a worktree; agentara is the sole writer (clone, fetch). That isolation is what makes this safe: `--reference` dependents can be broken by an ill-timed `git gc` on the shared object source, so the cache is intentionally off-limits to user workflows. Flow: - `ensureCachedMirror(repo)` lazily seeds the mirror on first use of a repo name and serializes concurrent callers through a per-mirror in-flight promise. Returns `null` on clone failure (network down, bad URL, disk full) so callers fall back to a plain clone — correctness over space. - `setup-flow._cloneAndCheckout` and the `/clone` handler both consult the cache; `/clone` matches the URL against REPOS.md (normalized for `.git` / trailing `/`) and only goes through the cache when it finds a predefined entry. Arbitrary URLs still do a plain clone. - `syncRepo` refreshes the matching cache mirror after each workspace fetch, so future `--reference` clones hit recent commits. No-op when the mirror doesn't exist. boot-loader ensures the `git-cache/` root exists; paths module gains `git_cache` and `resolveGitCachePath(name)` constants. Tests spin up a local file:// source repo and cover: bare-mirror layout on first clone, idempotent re-entry when mirror exists, null-return on bad URL with no partial dir left behind, concurrent ensure converging to the same result, and refresh success vs. no-op for missing mirror. All net-free. --- src/boot-loader/boot-loader.ts | 3 + src/kernel/commands/handlers.ts | 43 +++++- src/kernel/setup/setup-flow.ts | 17 ++- src/kernel/workspaces/git-cache.ts | 162 ++++++++++++++++++++++ src/kernel/workspaces/git-sync.ts | 9 ++ src/kernel/workspaces/index.ts | 1 + src/shared/config/paths.ts | 13 ++ tests/kernel/workspaces/git-cache.test.ts | 140 +++++++++++++++++++ 8 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 src/kernel/workspaces/git-cache.ts create mode 100644 tests/kernel/workspaces/git-cache.test.ts diff --git a/src/boot-loader/boot-loader.ts b/src/boot-loader/boot-loader.ts index 5df4ace..a3e4bbb 100644 --- a/src/boot-loader/boot-loader.ts +++ b/src/boot-loader/boot-loader.ts @@ -52,6 +52,9 @@ class BootLoader { if (!existsSync(config.paths.default_workspace)) { mkdirSync(config.paths.default_workspace, { recursive: true }); } + if (!existsSync(config.paths.git_cache)) { + mkdirSync(config.paths.git_cache, { recursive: true }); + } if (!existsSync(config.paths.memory)) { mkdirSync(config.paths.memory, { recursive: true }); diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 7b76d55..bbf12e4 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -3,12 +3,14 @@ import { basename, join } from "node:path"; import { formatRepoRef } from "@/kernel/repo-ref"; import { + ensureCachedMirror, formatAheadBehind, listRepoSyncState, readRepoHead, syncWorkspace, type RepoSyncResult, } from "@/kernel/workspaces"; +import { loadPredefinedRepos } from "@/shared"; import { buildCommandCard } from "./cards"; import type { @@ -289,8 +291,23 @@ const cloneHandler: CommandHandler = { if (existsSync(targetPath)) { return `❌ workspace 中已存在 \`${name}\`,请换个别名,或使用 \`/ls\` 查看已克隆的仓库。`; } - ctx.logger.info({ chat_id: chatId, url, name }, "cloning repo"); - const result = await execGit(["clone", url, name], workspacePath); + // If this URL matches a predefined repo in REPOS.md, route the + // clone through the shared object cache so the history is fetched + // at most once across all workspaces. Arbitrary URLs still do a + // plain clone — maintaining a cache entry for a one-off repo + // isn't worth the bookkeeping. + const catalogMatch = _findPredefinedByUrl(url); + const mirror = catalogMatch + ? await ensureCachedMirror(catalogMatch) + : null; + const cloneArgs = mirror + ? ["clone", "--reference", mirror, url, name] + : ["clone", url, name]; + ctx.logger.info( + { chat_id: chatId, url, name, using_cache: Boolean(mirror) }, + "cloning repo", + ); + const result = await execGit(cloneArgs, workspacePath); if (!result.ok) { return `❌ \`git clone\` 失败:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\``; } @@ -599,3 +616,25 @@ function deriveRepoName(url: string): string { const last = basename(tail); return last.endsWith(".git") ? last.slice(0, -4) : last; } + +/** + * Look up a predefined repo by its git URL, normalizing trailing `.git` + * and `/` so users copy-pasting the URL without the `.git` suffix still + * hit the cache. Returns `null` when the URL isn't in REPOS.md. + */ +function _findPredefinedByUrl( + url: string, +): { name: string; git_url: string } | null { + const norm = _normalizeGitUrl(url); + for (const repo of loadPredefinedRepos()) { + if (_normalizeGitUrl(repo.git_url) === norm) return repo; + } + return null; +} + +function _normalizeGitUrl(url: string): string { + let u = url.trim().toLowerCase(); + if (u.endsWith("/")) u = u.slice(0, -1); + if (u.endsWith(".git")) u = u.slice(0, -4); + return u; +} diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index 1b82a6f..236601f 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -14,7 +14,7 @@ import { } from "@/shared"; import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; -import type { GroupWorkspaceStore } from "../workspaces"; +import { ensureCachedMirror, type GroupWorkspaceStore } from "../workspaces"; import { buildSetupCard, @@ -428,10 +428,17 @@ export class SetupFlow { const alreadyCloned = existsSync(join(targetPath, ".git")); if (!alreadyCloned) { - const clone = await _execGit( - ["clone", sel.repo.git_url, sel.name], - workspacePath, - ); + // Seed the shared object cache (or reuse the existing mirror) so + // this clone and every future one for the same repo pays the + // network/disk cost of the history at most once. If the cache + // can't be established (first-time network failure, disk full, + // etc.) we silently fall back to a plain clone — correctness + // over space. + const mirror = await ensureCachedMirror(sel.repo); + const cloneArgs = mirror + ? ["clone", "--reference", mirror, sel.repo.git_url, sel.name] + : ["clone", sel.repo.git_url, sel.name]; + const clone = await _execGit(cloneArgs, workspacePath); if (!clone.ok) { return { name: sel.name, diff --git a/src/kernel/workspaces/git-cache.ts b/src/kernel/workspaces/git-cache.ts new file mode 100644 index 0000000..57e5912 --- /dev/null +++ b/src/kernel/workspaces/git-cache.ts @@ -0,0 +1,162 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +import { config, createLogger } from "@/shared"; + +const _logger = createLogger("git-cache"); + +/** + * On-disk object cache used as the `--reference` source when cloning + * predefined repos into workspaces. Each entry is a bare mirror + * (`git clone --mirror`) living at `$AGENTARA_HOME/git-cache/.git/`, + * maintained exclusively by agentara — users don't check out, commit, + * or run `git gc` inside these mirrors, so the `--reference` dependents + * can't have their objects pulled out from under them. + * + * Cache loss is survivable: callers fall back to a plain clone when + * `ensureCachedMirror` returns `null`, so a broken or absent cache just + * turns into "every clone is full again" — slow, but still correct. + */ + +export interface GitCacheOptions { + /** Override the cache root (tests). Defaults to `config.paths.git_cache`. */ + cacheRoot?: string; + /** Per-git-process timeout in ms. Defaults to 5 minutes. */ + timeoutMs?: number; +} + +const DEFAULT_CLONE_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_FETCH_TIMEOUT_MS = 2 * 60 * 1000; + +/** + * In-flight ensure operations keyed by absolute mirror path. Lets two + * concurrent chats that first-touch the same repo share a single clone + * process instead of racing on the target directory (the second clone + * would fail with "destination path exists"). + */ +const _inflight = new Map>(); + +/** + * Absolute path where the mirror for a given repo name lives, whether + * or not it currently exists on disk. + */ +export function cachedMirrorPath( + repoName: string, + options: Pick = {}, +): string { + const root = options.cacheRoot ?? config.paths.git_cache; + return join(root, `${repoName}.git`); +} + +/** + * Lazily ensure a bare mirror exists for the given repo. Returns the + * mirror path on success, or `null` on clone failure so callers can + * decide whether to fall back. Concurrent calls for the same name + * share a single clone process. + */ +export async function ensureCachedMirror( + repo: { name: string; git_url: string }, + options: GitCacheOptions = {}, +): Promise { + const mirror = cachedMirrorPath(repo.name, options); + if (existsSync(mirror)) return mirror; + + const inflight = _inflight.get(mirror); + if (inflight) return inflight; + + const task = _doClone(repo, mirror, options).finally(() => { + _inflight.delete(mirror); + }); + _inflight.set(mirror, task); + return task; +} + +/** + * Best-effort `git fetch` on an existing mirror. No-op when the mirror + * doesn't exist. Returns `true` on success, `false` on failure or + * missing mirror; caller decides whether to surface to the user. + */ +export async function refreshCachedMirror( + repoName: string, + options: GitCacheOptions = {}, +): Promise { + const mirror = cachedMirrorPath(repoName, options); + if (!existsSync(mirror)) return false; + const timeout = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + const res = await _execGit(["fetch", "--prune"], mirror, timeout); + if (!res.ok) { + _logger.warn( + { repo: repoName, mirror, stderr: res.stderr }, + "cache mirror fetch failed", + ); + } + return res.ok; +} + +async function _doClone( + repo: { name: string; git_url: string }, + mirror: string, + options: GitCacheOptions, +): Promise { + const root = options.cacheRoot ?? config.paths.git_cache; + if (!existsSync(root)) mkdirSync(root, { recursive: true }); + const timeout = options.timeoutMs ?? DEFAULT_CLONE_TIMEOUT_MS; + _logger.info({ repo: repo.name, mirror }, "seeding cache mirror"); + const res = await _execGit( + ["clone", "--mirror", repo.git_url, `${repo.name}.git`], + root, + timeout, + ); + if (!res.ok) { + _logger.warn( + { repo: repo.name, stderr: res.stderr }, + "cache mirror clone failed; caller will fall back to direct clone", + ); + // Git may leave a partial dir behind on failure; wipe it so the + // next attempt starts from a clean state instead of tripping over + // "destination path already exists". + try { + if (existsSync(mirror)) rmSync(mirror, { recursive: true, force: true }); + } catch (err) { + _logger.warn({ err, mirror }, "failed to clean up partial mirror dir"); + } + return null; + } + return mirror; +} + +async function _execGit( + args: string[], + cwd: string, + timeoutMs: number, +): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => { + try { + proc.kill(); + } catch { + // process may already be gone + } + }, timeoutMs); + try { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { + ok: code === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + code, + }; + } catch (err) { + return { ok: false, stdout: "", stderr: String(err), code: -1 }; + } finally { + clearTimeout(timer); + } +} diff --git a/src/kernel/workspaces/git-sync.ts b/src/kernel/workspaces/git-sync.ts index 680dbcd..3bddcf2 100644 --- a/src/kernel/workspaces/git-sync.ts +++ b/src/kernel/workspaces/git-sync.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { createLogger } from "@/shared"; +import { refreshCachedMirror } from "./git-cache"; + const _logger = createLogger("git-sync"); export interface RepoSyncState { @@ -116,6 +118,13 @@ export async function syncRepo( }; } + // Keep the shared object cache fresh so future `--reference` clones + // of this repo hit recent commits instead of re-downloading them. + // No-op when no cache mirror exists for this name. Best-effort — + // any failure is logged inside refreshCachedMirror and doesn't + // impact the workspace sync result. + await refreshCachedMirror(name, { timeoutMs: timeout }); + if (!branch) { const state = await _readState(repoPath); return { diff --git a/src/kernel/workspaces/index.ts b/src/kernel/workspaces/index.ts index 93fef2e..1accd65 100644 --- a/src/kernel/workspaces/index.ts +++ b/src/kernel/workspaces/index.ts @@ -1,2 +1,3 @@ +export * from "./git-cache"; export * from "./git-sync"; export * from "./store"; diff --git a/src/shared/config/paths.ts b/src/shared/config/paths.ts index 5d679c4..1f1c57e 100644 --- a/src/shared/config/paths.ts +++ b/src/shared/config/paths.ts @@ -50,6 +50,19 @@ export const outputs = join(workspace, "outputs"); */ export const workspaces = join(home, "workspaces"); export const default_workspace = join(workspaces, "_default"); + +/** + * Object-only cache of bare git mirrors, one per predefined repo name. + * Used as the `--reference` source when cloning repos into workspaces so + * object downloads are paid at most once across all workspaces. Never + * used as a worktree or user-facing workspace — agentara maintains these + * mirrors exclusively (clone, fetch). Losing this directory degrades to + * "every clone is a fresh full clone" — still correct, just slower. + */ +export const git_cache = join(home, "git-cache"); +export function resolveGitCachePath(repo_name: string) { + return join(git_cache, `${repo_name}.git`); +} export function resolveWorkspacePathById(workspace_id: string) { return join(workspaces, workspace_id); } diff --git a/tests/kernel/workspaces/git-cache.test.ts b/tests/kernel/workspaces/git-cache.test.ts new file mode 100644 index 0000000..c3a259f --- /dev/null +++ b/tests/kernel/workspaces/git-cache.test.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { + cachedMirrorPath, + ensureCachedMirror, + refreshCachedMirror, +} from "@/kernel/workspaces"; + +/** + * Initialize a minimal local git repo we can use as a clone source. + * Runs synchronously so individual tests can rely on it being ready. + */ +function _initSourceRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "agentara-test-src-")); + const run = (args: string[]) => { + const p = Bun.spawnSync(["git", ...args], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + if (p.exitCode !== 0) { + throw new Error( + `git ${args.join(" ")} failed: ${p.stderr.toString()}`, + ); + } + }; + run(["init", "--initial-branch=main"]); + run(["config", "user.email", "test@example.com"]); + run(["config", "user.name", "Test"]); + run(["config", "commit.gpgsign", "false"]); + writeFileSync(join(dir, "README.md"), "hello\n"); + run(["add", "."]); + run(["commit", "-m", "initial"]); + return dir; +} + +describe("cachedMirrorPath", () => { + test("joins cacheRoot with .git", () => { + expect(cachedMirrorPath("foo", { cacheRoot: "/tmp/cache" })).toBe( + "/tmp/cache/foo.git", + ); + }); +}); + +describe("ensureCachedMirror", () => { + let cacheRoot: string; + let sourceRepo: string; + + beforeAll(() => { + cacheRoot = mkdtempSync(join(tmpdir(), "agentara-test-cache-")); + sourceRepo = _initSourceRepo(); + }); + + afterAll(() => { + rmSync(cacheRoot, { recursive: true, force: true }); + rmSync(sourceRepo, { recursive: true, force: true }); + }); + + test("clones a bare mirror when absent", async () => { + const mirror = await ensureCachedMirror( + { name: "repo1", git_url: sourceRepo }, + { cacheRoot }, + ); + expect(mirror).toBe(join(cacheRoot, "repo1.git")); + // Bare repo layout: HEAD / config / objects at the top level, + // no worktree and no `.git` subdir. + expect(existsSync(join(mirror!, "HEAD"))).toBe(true); + expect(existsSync(join(mirror!, "objects"))).toBe(true); + expect(existsSync(join(mirror!, ".git"))).toBe(false); + }); + + test("is idempotent when mirror exists", async () => { + const first = await ensureCachedMirror( + { name: "repo1", git_url: sourceRepo }, + { cacheRoot }, + ); + const second = await ensureCachedMirror( + { name: "repo1", git_url: sourceRepo }, + { cacheRoot }, + ); + expect(first).toBe(second); + expect(first).not.toBeNull(); + }); + + test("returns null on clone failure and leaves no partial dir", async () => { + const mirror = await ensureCachedMirror( + { + name: "badrepo", + git_url: "/definitely-does-not-exist/nowhere.git", + }, + { cacheRoot, timeoutMs: 10_000 }, + ); + expect(mirror).toBeNull(); + expect(existsSync(join(cacheRoot, "badrepo.git"))).toBe(false); + }); + + test("concurrent calls for the same name yield the same result", async () => { + const name = `concurrent-${Date.now()}`; + const [a, b] = await Promise.all([ + ensureCachedMirror({ name, git_url: sourceRepo }, { cacheRoot }), + ensureCachedMirror({ name, git_url: sourceRepo }, { cacheRoot }), + ]); + expect(a).toBe(b); + expect(a).not.toBeNull(); + expect(existsSync(join(cacheRoot, `${name}.git`))).toBe(true); + }); +}); + +describe("refreshCachedMirror", () => { + let cacheRoot: string; + let sourceRepo: string; + + beforeAll(async () => { + cacheRoot = mkdtempSync(join(tmpdir(), "agentara-test-cache-")); + sourceRepo = _initSourceRepo(); + await ensureCachedMirror( + { name: "repo1", git_url: sourceRepo }, + { cacheRoot }, + ); + }); + + afterAll(() => { + rmSync(cacheRoot, { recursive: true, force: true }); + rmSync(sourceRepo, { recursive: true, force: true }); + }); + + test("fetches existing mirror successfully", async () => { + const ok = await refreshCachedMirror("repo1", { cacheRoot }); + expect(ok).toBe(true); + }); + + test("returns false when the mirror doesn't exist (no-op)", async () => { + const ok = await refreshCachedMirror("nonexistent", { cacheRoot }); + expect(ok).toBe(false); + }); +}); From 66b6aa7e30277541a798f83dfe24bfaea4b16944 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 11:55:59 +0800 Subject: [PATCH 40/69] feat(kernel): add runtime agent switching commands Add /agent and /agents slash commands so operators can inspect available runners and switch the runtime default without editing config.yaml or restarting the process. Use the runtime default only for newly-created sessions while preserving the stored agent_type for existing sessions to avoid mixing runner resume state. --- src/kernel/agents/index.ts | 1 + src/kernel/agents/runtime-default.ts | 103 ++++++++++++++++++ src/kernel/commands/handlers.ts | 113 +++++++++++++++++++- src/kernel/sessioning/session-manager.ts | 10 +- tests/kernel/commands/agent-command.test.ts | 97 +++++++++++++++++ 5 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 src/kernel/agents/runtime-default.ts create mode 100644 tests/kernel/commands/agent-command.test.ts diff --git a/src/kernel/agents/index.ts b/src/kernel/agents/index.ts index 7f5d35e..7f7ff7f 100644 --- a/src/kernel/agents/index.ts +++ b/src/kernel/agents/index.ts @@ -1,2 +1,3 @@ export * from "./factory"; export * from "./registry"; +export * from "./runtime-default"; diff --git a/src/kernel/agents/runtime-default.ts b/src/kernel/agents/runtime-default.ts new file mode 100644 index 0000000..ad0acad --- /dev/null +++ b/src/kernel/agents/runtime-default.ts @@ -0,0 +1,103 @@ +import { config } from "@/shared"; + +import { listRunnerTypes } from "./registry"; + +let runtimeDefaultAgentType: string | null = null; + +export interface AgentRuntimeState { + activeType: string; + configuredDefaultType: string | null; + hasRuntimeOverride: boolean; + availableTypes: string[]; +} + +export interface SetRuntimeDefaultAgentResult { + previousType: string; + currentType: string; + changed: boolean; +} + +/** + * Agent type used when creating a new session. Existing sessions keep the + * agent_type stored in the DB so runner-specific resume state is not mixed. + */ +export function getRuntimeDefaultAgentType(): string { + return runtimeDefaultAgentType ?? config.agents.default.type; +} + +export function getAgentRuntimeState(): AgentRuntimeState { + const configuredDefaultType = _readConfiguredDefaultAgentType(); + return { + activeType: + runtimeDefaultAgentType ?? + configuredDefaultType ?? + listRunnerTypes()[0] ?? + "", + configuredDefaultType, + hasRuntimeOverride: runtimeDefaultAgentType !== null, + availableTypes: listRunnerTypes(), + }; +} + +export function setRuntimeDefaultAgentType( + type: string, +): SetRuntimeDefaultAgentResult { + const normalized = type.trim(); + const availableTypes = listRunnerTypes(); + const resolvedType = _resolveAvailableType(normalized, availableTypes); + if (!resolvedType) { + throw new UnknownAgentTypeError(normalized, availableTypes); + } + const previousType = getAgentRuntimeState().activeType; + runtimeDefaultAgentType = resolvedType; + return { + previousType, + currentType: resolvedType, + changed: previousType !== resolvedType, + }; +} + +export function resetRuntimeDefaultAgentType(): SetRuntimeDefaultAgentResult { + const configuredDefaultType = _readConfiguredDefaultAgentType(); + if (!configuredDefaultType) { + throw new Error("config.yaml has not been loaded yet."); + } + const previousType = getAgentRuntimeState().activeType; + runtimeDefaultAgentType = null; + return { + previousType, + currentType: configuredDefaultType, + changed: previousType !== configuredDefaultType, + }; +} + +export class UnknownAgentTypeError extends Error { + constructor( + readonly type: string, + readonly availableTypes: string[], + ) { + super( + `Unknown agent type: ${type}. Available types: ${ + availableTypes.join(", ") || "" + }.`, + ); + this.name = "UnknownAgentTypeError"; + } +} + +function _readConfiguredDefaultAgentType(): string | null { + try { + return config.agents.default.type; + } catch { + return null; + } +} + +function _resolveAvailableType( + input: string, + availableTypes: string[], +): string | null { + if (availableTypes.includes(input)) return input; + const lowered = input.toLowerCase(); + return availableTypes.find((type) => type.toLowerCase() === lowered) ?? null; +} diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index bbf12e4..f7d3a2e 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -1,6 +1,12 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; +import { + getAgentRuntimeState, + resetRuntimeDefaultAgentType, + setRuntimeDefaultAgentType, + UnknownAgentTypeError, +} from "@/kernel/agents"; import { formatRepoRef } from "@/kernel/repo-ref"; import { ensureCachedMirror, @@ -77,6 +83,10 @@ function cardReply( summary?: string; }, ): CardCommandResult { + const fallbackLines = [title, ...lines]; + for (const section of options?.sections ?? []) { + fallbackLines.push(section.title, ...section.lines); + } return { kind: "card", card: buildCommandCard({ @@ -85,10 +95,109 @@ function cardReply( sections: options?.sections, summary: options?.summary, }), - fallback_text: [title, ...lines].join("\n"), + fallback_text: fallbackLines.join("\n"), }; } +const AGENT_USAGE = + "用法:`/agent`、`/agent list`、`/agent use `、`/agent reset`"; + +function agentStatusReply(): CardCommandResult { + const state = getAgentRuntimeState(); + const source = state.hasRuntimeOverride ? "运行时" : "配置"; + const lines = [ + `- 当前默认 Agent:\`${state.activeType}\`(${source})`, + state.configuredDefaultType + ? `- 配置默认:\`${state.configuredDefaultType}\`` + : "- 配置默认:(config.yaml 未加载)", + "- 影响范围:之后创建的新 session;已有 session 会继续使用创建时记录的 Agent。", + ]; + const agentLines = + state.availableTypes.length > 0 + ? state.availableTypes.map((type) => { + const marks: string[] = []; + if (type === state.activeType) marks.push("当前"); + if (type === state.configuredDefaultType) marks.push("配置默认"); + return `- \`${type}\`${marks.length ? ` ← ${marks.join(" / ")}` : ""}`; + }) + : ["- (当前没有注册任何 Agent runner)"]; + return cardReply("Agent 管理", lines, { + sections: [{ title: "可选 Agent", lines: agentLines }], + }); +} + +const agentHandler: CommandHandler = { + name: "agent", + description: "/agent [list|use |reset] — 查看或切换运行时默认 Agent", + async execute(ctx) { + const [verbRaw, typeRaw] = ctx.args; + const verb = verbRaw?.toLowerCase(); + if (!verb || verb === "list" || verb === "status") { + return agentStatusReply(); + } + + if (verb === "reset" || verb === "default") { + const result = resetRuntimeDefaultAgentType(); + return cardReply("Agent 管理", [ + result.changed + ? `✅ 已恢复配置默认 Agent:\`${result.currentType}\`` + : `ℹ️ 当前已经是配置默认 Agent:\`${result.currentType}\``, + ]); + } + + const requestedType = + verb === "use" || verb === "switch" || verb === "set" + ? typeRaw + : verbRaw; + if (!requestedType) { + return cardReply("Agent 管理", [ + AGENT_USAGE, + "- 可先执行 `/agent list` 查看可选项。", + ]); + } + + try { + const result = setRuntimeDefaultAgentType(requestedType); + ctx.logger.info( + { + previous_agent_type: result.previousType, + current_agent_type: result.currentType, + }, + "runtime default agent switched", + ); + return cardReply("Agent 管理", [ + result.changed + ? `✅ 已切换运行时默认 Agent:\`${result.previousType}\` → \`${result.currentType}\`` + : `ℹ️ 当前默认 Agent 已经是 \`${result.currentType}\``, + "- 之后创建的新 session 会使用这个 Agent;已有 session 不会被强制切换。", + ]); + } catch (err) { + if (err instanceof UnknownAgentTypeError) { + return cardReply("Agent 管理", [ + `❌ 未知 Agent:\`${err.type}\``, + AGENT_USAGE, + ], { + sections: [ + { + title: "可选 Agent", + lines: err.availableTypes.map((type) => `- \`${type}\``), + }, + ], + }); + } + throw err; + } + }, +}; + +const agentsHandler: CommandHandler = { + name: "agents", + description: "/agents — 查看可选 Agent 列表", + async execute() { + return agentStatusReply(); + }, +}; + const bindHandler: CommandHandler = { name: "bind", description: "/bind [workspace-id] — 绑定当前群到一个 workspace;传 id 时复用已有空间", @@ -549,6 +658,8 @@ export const helpHandler: CommandHandler = { }; export const BUILTIN_COMMANDS: CommandHandler[] = [ + agentHandler, + agentsHandler, bindHandler, unbindHandler, statusHandler, diff --git a/src/kernel/sessioning/session-manager.ts b/src/kernel/sessioning/session-manager.ts index 2c90b50..9db3a4a 100644 --- a/src/kernel/sessioning/session-manager.ts +++ b/src/kernel/sessioning/session-manager.ts @@ -6,6 +6,8 @@ import type { DrizzleDB } from "@/data"; import { config, createLogger, extractTextContent, uuid } from "@/shared"; import type { Session as SessionEntity, UserMessage } from "@/shared"; +import { getRuntimeDefaultAgentType } from "../agents"; + import { sessions } from "./data"; import { Session } from "./session"; import { @@ -21,7 +23,7 @@ import { export interface SessionResolveOptions { /** * The type of agent runner (e.g. "claude-code"). - * Defaults to `config.agents.default.type`. + * Defaults to the runtime default agent type. */ agentType?: string; @@ -95,7 +97,7 @@ export class SessionManager { /** * Resolves session by database existence: creates if missing, resumes if exists. * @param sessionId - The session identifier. - * @param options - Optional agent_type and cwd (default from config). + * @param options - Optional agent_type and cwd (default from runtime/config). * @returns A Session instance. */ async resolveSession( @@ -111,7 +113,7 @@ export class SessionManager { /** * Creates a new session and inserts a row into the database. * @param sessionId - The session identifier. - * @param options - Optional agent_type and cwd (default from config). + * @param options - Optional agent_type and cwd (default from runtime/config). * @returns A Session instance with isNewSession: true. * @throws SessionAlreadyExistsError if the session already exists. */ @@ -123,7 +125,7 @@ export class SessionManager { throw new SessionAlreadyExistsError(sessionId); } - const agentType = options?.agentType ?? config.agents.default.type; + const agentType = options?.agentType ?? getRuntimeDefaultAgentType(); const cwd = options?.cwd ?? config.paths.home; const channelId = options?.channelId ?? null; const chatId = options?.chatId ?? null; diff --git a/tests/kernel/commands/agent-command.test.ts b/tests/kernel/commands/agent-command.test.ts new file mode 100644 index 0000000..26bc165 --- /dev/null +++ b/tests/kernel/commands/agent-command.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; + +import { + getRuntimeDefaultAgentType, + setRuntimeDefaultAgentType, +} from "@/kernel/agents"; +import { + CommandRegistry, + type CommandContext, + type CommandHandler, +} from "@/kernel/commands"; + +function getHandler(name: string): CommandHandler { + const handler = new CommandRegistry().get(name); + if (!handler) throw new Error(`missing command handler: ${name}`); + return handler; +} + +function makeContext(args: string[]): CommandContext { + return { + args, + raw: `/${args.join(" ")}`, + message: { + id: "msg_1", + role: "user", + session_id: "session_1", + channel_id: "ch_1", + chat_id: "oc_1", + chat_type: "group", + content: [{ type: "text", text: "/agent" }], + }, + workspaceStore: {}, + feishuChannels: new Map(), + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + trace() {}, + fatal() {}, + child() { + return this; + }, + }, + } as unknown as CommandContext; +} + +describe("agent management commands", () => { + test("/agents lists available runners and marks the active one", async () => { + setRuntimeDefaultAgentType("codex"); + const result = await getHandler("agents").execute(makeContext([])); + + expect(typeof result).not.toBe("string"); + if (typeof result === "string") throw new Error("expected card result"); + expect(result.fallback_text).toContain("当前默认 Agent"); + expect(result.fallback_text).toContain("可选 Agent"); + expect(result.fallback_text).toContain("`codex`"); + expect(result.fallback_text).toContain("当前"); + }); + + test("/agent use switches the runtime default for new sessions", async () => { + setRuntimeDefaultAgentType("claude"); + const result = await getHandler("agent").execute( + makeContext(["use", "dummy"]), + ); + + expect(getRuntimeDefaultAgentType()).toBe("dummy"); + expect(typeof result).not.toBe("string"); + if (typeof result === "string") throw new Error("expected card result"); + expect(result.fallback_text).toContain("dummy"); + expect(result.fallback_text).toContain("新 session"); + }); + + test("/agent accepts a direct runner type shorthand", async () => { + setRuntimeDefaultAgentType("claude"); + await getHandler("agent").execute(makeContext(["codex"])); + + expect(getRuntimeDefaultAgentType()).toBe("codex"); + }); + + test("/agent matches runner types case-insensitively", async () => { + setRuntimeDefaultAgentType("claude"); + await getHandler("agent").execute(makeContext(["Codex"])); + + expect(getRuntimeDefaultAgentType()).toBe("codex"); + }); + + test("/agent rejects unknown runner types with the available list", async () => { + const result = await getHandler("agent").execute(makeContext(["missing"])); + + expect(typeof result).not.toBe("string"); + if (typeof result === "string") throw new Error("expected card result"); + expect(result.fallback_text).toContain("未知 Agent"); + expect(result.fallback_text).toContain("可选 Agent"); + expect(result.fallback_text).toContain("`codex`"); + }); +}); From daeb0a6f29fb97b56c8fc8c558931d71128a8d2e Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 13:23:26 +0800 Subject: [PATCH 41/69] feat(codex): add yolo runner and resume recovery Replace the removed codex-gated plugin with codex-yolo, which injects the fixed local proxy and enables Codex web search while preserving existing Codex exec behavior. Surface Codex resume rollout loss on the Feishu card with an explicit restart button instead of silently starting a new runner session. Legacy codex-gated sessions are mapped to codex-yolo, and restart confirmation clears stale runner ids before re-dispatching. --- src/community/openai/codex-agent-runner.ts | 133 ++++++++++++--- src/kernel/agent-failure.ts | 31 ++++ src/kernel/agents/aliases.ts | 11 ++ src/kernel/agents/index.ts | 1 + src/kernel/codex-resume-card.ts | 105 ++++++++++++ src/kernel/kernel.ts | 155 ++++++++++++++++-- src/kernel/sessioning/session-manager.ts | 58 ++++++- src/plugins/codex-gated.ts | 72 -------- src/plugins/codex-yolo.ts | 46 ++++++ src/plugins/index.ts | 2 +- src/shared/tasking/types/payload.ts | 1 + .../openai/codex-agent-runner.test.ts | 31 ++++ tests/kernel/agent-failure.test.ts | 42 +++++ tests/kernel/agents/aliases.test.ts | 19 +++ tests/kernel/codex-resume-card.test.ts | 43 +++++ 15 files changed, 633 insertions(+), 117 deletions(-) create mode 100644 src/kernel/agent-failure.ts create mode 100644 src/kernel/agents/aliases.ts create mode 100644 src/kernel/codex-resume-card.ts delete mode 100644 src/plugins/codex-gated.ts create mode 100644 src/plugins/codex-yolo.ts create mode 100644 tests/kernel/agent-failure.test.ts create mode 100644 tests/kernel/agents/aliases.test.ts create mode 100644 tests/kernel/codex-resume-card.test.ts diff --git a/src/community/openai/codex-agent-runner.ts b/src/community/openai/codex-agent-runner.ts index db7c897..e1d9576 100644 --- a/src/community/openai/codex-agent-runner.ts +++ b/src/community/openai/codex-agent-runner.ts @@ -17,6 +17,19 @@ import { const logger = createLogger("codex-agent-runner"); +export interface CodexAgentRunnerOptions { + extraGlobalArgs?: string[]; + extraExecArgs?: string[]; +} + +interface CodexSpawnOptions { + args: string[]; + cwd: string; + env: Record; + signal?: AbortSignal; + sessionId: string; +} + /** * Error thrown when the agent runner is aborted. */ @@ -27,6 +40,21 @@ export class AgentAbortError extends Error { } } +export class CodexMissingResumeError extends Error { + readonly causeError: unknown; + + constructor( + readonly resumeId: string, + causeError: unknown, + ) { + super( + `Codex 无法续接本地 thread:${resumeId}。请确认是否重新开始 Codex 会话。`, + ); + this.name = "CodexMissingResumeError"; + this.causeError = causeError; + } +} + /** * The agent runner for OpenAI Codex CLI. * @@ -36,6 +64,11 @@ export class AgentAbortError extends Error { */ export class CodexAgentRunner implements AgentRunner { readonly type = "codex"; + private readonly _options: CodexAgentRunnerOptions; + + constructor(options: CodexAgentRunnerOptions = {}) { + this._options = options; + } async *stream( message: UserMessage, @@ -70,31 +103,53 @@ export class CodexAgentRunner implements AgentRunner { const isolationEnv = config.agents.codex.isolate_host_env ? { CODEX_HOME: config.paths.codex_home } : {}; + const env = { + ...Bun.env, + ...config.agents.env, + ...isolationEnv, + ...(options.envExtras ?? {}), + }; + + try { + yield* this._streamCodexProcess({ + args, + cwd: options.cwd, + env, + signal, + sessionId, + }); + } catch (err) { + if (!isNew && this._isMissingResumeError(err)) { + throw new CodexMissingResumeError(resumeId, err); + } + throw err; + } + } - const proc = Bun.spawn(args, { + private async *_streamCodexProcess( + options: CodexSpawnOptions, + ): AsyncIterableIterator { + const proc = Bun.spawn(options.args, { cwd: options.cwd, - env: { - ...Bun.env, - ...config.agents.env, - ...isolationEnv, - ...(options.envExtras ?? {}), - }, + env: options.env, stderr: "pipe", }); - // Handle abort signal let aborted = false; const abortHandler = () => { aborted = true; - logger.info({ session_id: sessionId }, "killing Codex CLI process"); + logger.info( + { session_id: options.sessionId }, + "killing Codex CLI process", + ); proc.kill(); }; - if (signal) { - if (signal.aborted) { + if (options.signal) { + if (options.signal.aborted) { proc.kill(); throw new AgentAbortError(); } - signal.addEventListener("abort", abortHandler, { once: true }); + options.signal.addEventListener("abort", abortHandler, { once: true }); } const decoder = new TextDecoder(); @@ -121,7 +176,10 @@ export class CodexAgentRunner implements AgentRunner { buffer = lines.pop()!; for (const line of lines) { if (line.trim()) { - const messages = this._parseStreamLine(line.trim(), sessionId); + const messages = this._parseStreamLine( + line.trim(), + options.sessionId, + ); for (const msg of messages) { yield msg; } @@ -130,14 +188,14 @@ export class CodexAgentRunner implements AgentRunner { } if (!aborted && buffer.trim()) { - const messages = this._parseStreamLine(buffer.trim(), sessionId); + const messages = this._parseStreamLine(buffer.trim(), options.sessionId); for (const msg of messages) { yield msg; } } } finally { - if (signal) { - signal.removeEventListener("abort", abortHandler); + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); } } @@ -152,15 +210,7 @@ export class CodexAgentRunner implements AgentRunner { stderrChunks.length > 0 ? decoder.decode(Bun.concatArrayBuffers(stderrChunks)) : ""; - const parts: string[] = []; - if (stdoutRaw.trim()) { - parts.push(`Stdout:\n${stdoutRaw.trim()}`); - } - if (stderrText.trim()) { - parts.push(`Stderr:\n${stderrText.trim()}`); - } - const detail = parts.length > 0 ? `\n\n${parts.join("\n\n")}` : ""; - throw new Error(`Codex CLI exited with code ${exitCode}${detail}`); + throw new CodexCliExitError(exitCode, stdoutRaw, stderrText); } } @@ -493,6 +543,7 @@ export class CodexAgentRunner implements AgentRunner { const configuredModel = config.agents.default.model; const shared = [ "codex", + ...(this._options.extraGlobalArgs ?? []), "exec", // Only pin the model when config names one; otherwise Codex CLI // picks its own default (user omitted `model` in config.yaml). @@ -500,6 +551,7 @@ export class CodexAgentRunner implements AgentRunner { "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", + ...(this._options.extraExecArgs ?? []), ]; if (isNew) { return [...shared, prompt]; @@ -507,6 +559,18 @@ export class CodexAgentRunner implements AgentRunner { return [...shared, "resume", resumeId, prompt]; } + private _isMissingResumeError(err: unknown): boolean { + if (!(err instanceof CodexCliExitError)) return false; + return this._isMissingResumeErrorText(err.stderr); + } + + private _isMissingResumeErrorText(text: string): boolean { + return ( + text.includes("thread/resume failed") && + text.includes("no rollout found for thread id") + ); + } + /** * Reads `CLAUDE.md` from `cwd`, resolves any `@path/file` imports, and * writes the result as `AGENTS.md` so the Codex CLI can pick it up as its @@ -543,3 +607,22 @@ export class CodexAgentRunner implements AgentRunner { } } } + +class CodexCliExitError extends Error { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + const parts: string[] = []; + if (stdout.trim()) { + parts.push(`Stdout:\n${stdout.trim()}`); + } + if (stderr.trim()) { + parts.push(`Stderr:\n${stderr.trim()}`); + } + const detail = parts.length > 0 ? `\n\n${parts.join("\n\n")}` : ""; + super(`Codex CLI exited with code ${exitCode}${detail}`); + this.name = "CodexCliExitError"; + } +} diff --git a/src/kernel/agent-failure.ts b/src/kernel/agent-failure.ts new file mode 100644 index 0000000..5cf9997 --- /dev/null +++ b/src/kernel/agent-failure.ts @@ -0,0 +1,31 @@ +import type { AssistantMessage } from "@/shared"; + +export function buildAgentCancelledContent(): AssistantMessage["content"] { + return [ + { + type: "text", + text: "⏹️ 任务已取消。", + }, + ]; +} + +export function buildAgentFailureContent( + err: unknown, +): AssistantMessage["content"] { + const message = err instanceof Error ? err.message : String(err); + const detail = message.trim() || "未知错误"; + return [ + { + type: "text", + text: [ + "❌ Agent 启动或执行失败。", + "", + "```", + detail, + "```", + "", + "可以先检查当前 Agent 类型、代理/出口环境,或执行 `/agent use ` 后新开会话重试。", + ].join("\n"), + }, + ]; +} diff --git a/src/kernel/agents/aliases.ts b/src/kernel/agents/aliases.ts new file mode 100644 index 0000000..8ecda9b --- /dev/null +++ b/src/kernel/agents/aliases.ts @@ -0,0 +1,11 @@ +const LEGACY_AGENT_TYPE_ALIASES: Record = { + "codex-gated": "codex-yolo", +}; + +export function resolveAgentTypeAlias(type: string): string { + return LEGACY_AGENT_TYPE_ALIASES[type] ?? type; +} + +export function isLegacyAgentTypeAlias(type: string): boolean { + return LEGACY_AGENT_TYPE_ALIASES[type] !== undefined; +} diff --git a/src/kernel/agents/index.ts b/src/kernel/agents/index.ts index 7f7ff7f..9649c19 100644 --- a/src/kernel/agents/index.ts +++ b/src/kernel/agents/index.ts @@ -1,3 +1,4 @@ +export * from "./aliases"; export * from "./factory"; export * from "./registry"; export * from "./runtime-default"; diff --git a/src/kernel/codex-resume-card.ts b/src/kernel/codex-resume-card.ts new file mode 100644 index 0000000..8de7849 --- /dev/null +++ b/src/kernel/codex-resume-card.ts @@ -0,0 +1,105 @@ +import type { + ButtonElement, + Card, + ColumnSetElement, +} from "@/community/feishu/messaging/types"; + +import { buildCardIntro, buildMarkdown, buildResultCard } from "./setup/card-ui"; + +export const CODEX_RESUME_RESTART_ACTION = "codex_resume_restart"; + +export interface CodexResumeRestartValue { + action: typeof CODEX_RESUME_RESTART_ACTION; + [key: string]: unknown; +} + +export function buildCodexResumeMissingCard(options: { + resumeId: string; +}): Card { + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { + content: "Codex 续接失败", + }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements: [ + buildCardIntro({ + title: "Codex 续接失败", + subtitle: "本地 Codex 找不到上一轮 rollout", + }), + buildMarkdown( + [ + `- Thread ID:\`${options.resumeId}\``, + "- 原因:Codex 本地会话库里没有这个 rollout,可能是 `CODEX_HOME` 变化、会话文件被清理,或旧会话记录来自不同运行环境。", + "- 当前不会自动重开。确认可以丢弃 Codex CLI 的续跑状态后,再点击下面按钮。", + ].join("\n"), + ), + _buildRestartButtonRow(), + ], + }, + }; +} + +export function buildCodexResumeRestartingCard(): Card { + return buildResultCard({ + title: "Codex 续接恢复", + summary: "⏳ 正在重新开始 Codex 会话…", + }); +} + +export function buildCodexResumeRestartedCard(): Card { + return buildResultCard({ + title: "Codex 续接恢复", + summary: "✅ 已重新开始 Codex 会话,新回复会在话题内继续生成。", + }); +} + +export function buildCodexResumeExpiredCard(): Card { + return buildResultCard({ + title: "Codex 续接恢复", + summary: "⚠️ 这张恢复卡片已失效,请重新发送消息触发恢复提示。", + }); +} + +export function formatCodexResumeMissingText(resumeId: string): string { + return [ + "Codex 续接失败:本地 Codex 找不到上一轮 rollout。", + "", + `Thread ID:${resumeId}`, + "", + "当前不会自动重开。请在 Feishu 卡片上点击「重新开始 Codex 会话」,或手动开启新会话。", + ].join("\n"); +} + +function _buildRestartButtonRow(): ColumnSetElement { + const value: CodexResumeRestartValue = { + action: CODEX_RESUME_RESTART_ACTION, + }; + const button: ButtonElement = { + tag: "button", + name: "codex_resume_restart", + text: { tag: "plain_text", content: "重新开始 Codex 会话" }, + type: "primary", + width: "fill", + behaviors: [{ type: "callback", value }], + }; + return { + tag: "column_set", + flex_mode: "stretch", + columns: [ + { + tag: "column", + width: "weighted", + weight: 1, + elements: [button], + }, + ], + }; +} diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 9d3ed96..c42e9ed 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -1,6 +1,7 @@ import { FeishuMessageChannel } from "@/community/feishu"; import * as feishuMessagingSchema from "@/community/feishu/messaging/data"; import type { Card } from "@/community/feishu/messaging/types"; +import { CodexMissingResumeError } from "@/community/openai"; import { DataConnection } from "@/data"; // Side-effect import: every module under `src/plugins/*` registers its // runner with the registry at load time. This must happen before any @@ -18,6 +19,18 @@ import { import { HonoServer } from "../server"; +import { + buildAgentCancelledContent, + buildAgentFailureContent, +} from "./agent-failure"; +import { + buildCodexResumeExpiredCard, + buildCodexResumeMissingCard, + buildCodexResumeRestartedCard, + buildCodexResumeRestartingCard, + CODEX_RESUME_RESTART_ACTION, + formatCodexResumeMissingText, +} from "./codex-resume-card"; import { buildNewCommandRejectionReply, buildNewCommandUsageReply, @@ -59,6 +72,10 @@ class Kernel { private _switchFlow!: SwitchFlow; private _groupFlow!: GroupFlow; private _permissionFlow!: PermissionFlow; + private _codexResumeRestarts = new Map< + string, + { sessionId: string; message: UserMessage } + >(); constructor() { this._initDatabase(); @@ -541,6 +558,10 @@ class Kernel { await this._permissionFlow.handleDecide(payload); return; } + if (payload.action_name === CODEX_RESUME_RESTART_ACTION) { + await this._handleCodexResumeRestart(payload); + return; + } this._logger.warn( { action_name: payload.action_name, message_id: payload.message_id }, "unhandled card action", @@ -561,6 +582,7 @@ class Kernel { threadId: inboundMessage.thread_id, cwd: resolution.cwd, envExtras: resolution.envExtras, + forceNewRunnerSession: payload.forceNewRunnerSession, firstMessage: inboundMessage, }); let contents: AssistantMessage["content"] = [ @@ -581,22 +603,55 @@ class Kernel { }, ); contents = []; - const stream = await session.stream(inboundMessage, { signal }); let lastMessage: AssistantMessage | undefined; - for await (const message of stream) { - if (message.role === "assistant") { - contents.push(...message.content); + try { + const stream = await session.stream(inboundMessage, { signal }); + for await (const message of stream) { + if (message.role === "assistant") { + contents.push(...message.content); + await this._messageGateway.updateMessageContent( + { ...outboundMessage, content: contents }, + { + streaming: true, + }, + ); + lastMessage = message; + } + } + if (!lastMessage) { + throw new Error("No assistant message received from the agent."); + } + } catch (err) { + if (err instanceof CodexMissingResumeError) { + await this._handleCodexMissingResume( + err, + inboundMessage, + session.id, + outboundMessage, + ); + throw err; + } + const failureContent = signal?.aborted + ? buildAgentCancelledContent() + : buildAgentFailureContent(err); + try { await this._messageGateway.updateMessageContent( - { ...outboundMessage, content: contents }, + { ...outboundMessage, content: failureContent }, { - streaming: true, + streaming: false, }, ); - lastMessage = message; + } catch (updateErr) { + this._logger.error( + { + err: updateErr, + session_id: session.id, + outbound_message_id: outboundMessage.id, + }, + "failed to update assistant message after agent failure", + ); } - } - if (!lastMessage) { - throw new Error("No assistant message received from the agent."); + throw err; } await this._messageGateway.updateMessageContent( { ...outboundMessage, content: contents }, @@ -606,6 +661,86 @@ class Kernel { ); }; + private async _handleCodexMissingResume( + err: CodexMissingResumeError, + inboundMessage: UserMessage, + sessionId: string, + outboundMessage: AssistantMessage, + ): Promise { + const card = buildCodexResumeMissingCard({ resumeId: err.resumeId }); + const channel = + inboundMessage.channel_id && inboundMessage.chat_id + ? this._feishuChannels.get(inboundMessage.channel_id) + : undefined; + if (channel) { + try { + await channel.updateRawCard(outboundMessage.id, card); + this._codexResumeRestarts.set(outboundMessage.id, { + sessionId, + message: inboundMessage, + }); + return; + } catch (updateErr) { + this._logger.error( + { + err: updateErr, + session_id: sessionId, + outbound_message_id: outboundMessage.id, + }, + "failed to update Codex resume recovery card", + ); + } + } + + await this._messageGateway.updateMessageContent( + { + ...outboundMessage, + content: [ + { + type: "text", + text: formatCodexResumeMissingText(err.resumeId), + }, + ], + }, + { streaming: false }, + ); + } + + private async _handleCodexResumeRestart( + payload: CardActionPayload, + ): Promise { + const pending = this._codexResumeRestarts.get(payload.message_id); + const channel = this._feishuChannels.get(payload.channel_id); + if (!pending) { + if (channel) { + await channel.updateRawCard( + payload.message_id, + buildCodexResumeExpiredCard(), + ); + } + return; + } + this._codexResumeRestarts.delete(payload.message_id); + if (channel) { + await channel.updateRawCard( + payload.message_id, + buildCodexResumeRestartingCard(), + ); + } + this._sessionManager.resetRunnerSessionId(pending.sessionId); + await this._taskDispatcher.dispatch(pending.sessionId, { + type: "inbound_message", + message: pending.message, + forceNewRunnerSession: true, + }); + if (channel) { + await channel.updateRawCard( + payload.message_id, + buildCodexResumeRestartedCard(), + ); + } + } + private _handleScheduledTask = async ( _taskId: string, sessionId: string, diff --git a/src/kernel/sessioning/session-manager.ts b/src/kernel/sessioning/session-manager.ts index 9db3a4a..576960b 100644 --- a/src/kernel/sessioning/session-manager.ts +++ b/src/kernel/sessioning/session-manager.ts @@ -1,12 +1,12 @@ import { existsSync, unlinkSync } from "node:fs"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import type { DrizzleDB } from "@/data"; import { config, createLogger, extractTextContent, uuid } from "@/shared"; import type { Session as SessionEntity, UserMessage } from "@/shared"; -import { getRuntimeDefaultAgentType } from "../agents"; +import { getRuntimeDefaultAgentType, resolveAgentTypeAlias } from "../agents"; import { sessions } from "./data"; import { Session } from "./session"; @@ -54,6 +54,12 @@ export interface SessionResolveOptions { */ envExtras?: Record; + /** + * Resume the Agentara session row but start a fresh underlying runner + * session/thread. Used when a provider-specific resume id is stale. + */ + forceNewRunnerSession?: boolean; + /** * The first message of the session. */ @@ -125,7 +131,8 @@ export class SessionManager { throw new SessionAlreadyExistsError(sessionId); } - const agentType = options?.agentType ?? getRuntimeDefaultAgentType(); + const requestedAgentType = options?.agentType ?? getRuntimeDefaultAgentType(); + const agentType = resolveAgentTypeAlias(requestedAgentType); const cwd = options?.cwd ?? config.paths.home; const channelId = options?.channelId ?? null; const chatId = options?.chatId ?? null; @@ -156,6 +163,16 @@ export class SessionManager { ); } + if (agentType !== requestedAgentType) { + this._logger.info( + { + session_id: sessionId, + requested_agent_type: requestedAgentType, + resolved_agent_type: agentType, + }, + "legacy agent type resolved for new session", + ); + } this._logger.info(`Creating session: ${sessionId}`); const session = new Session(sessionId, agentType, { isNewSession: true, @@ -189,15 +206,29 @@ export class SessionManager { throw new SessionNotFoundError(sessionId); } + const requestedAgentType = options?.agentType ?? row.agent_type; + const agentType = resolveAgentTypeAlias(requestedAgentType); + if (agentType !== requestedAgentType) { + this._logger.info( + { + session_id: sessionId, + requested_agent_type: requestedAgentType, + resolved_agent_type: agentType, + }, + "legacy agent type resolved for resumed session", + ); + } this._logger.info(`Resuming session: ${sessionId}`); const session = new Session( sessionId, - options?.agentType ?? row.agent_type, + agentType, { - isNewSession: false, + isNewSession: options?.forceNewRunnerSession ?? false, cwd: options?.cwd ?? row.cwd, envExtras: options?.envExtras, - runnerSessionId: row.runner_session_id ?? undefined, + runnerSessionId: options?.forceNewRunnerSession + ? undefined + : row.runner_session_id ?? undefined, }, ); this._attachWriter(session, sessionId); @@ -235,6 +266,17 @@ export class SessionManager { this._logger.info(`Removed session: ${sessionId}`); } + resetRunnerSessionId(sessionId: string): void { + this._db + .update(sessions) + .set({ + runner_session_id: null, + updated_at: Date.now(), + }) + .where(eq(sessions.id, sessionId)) + .run(); + } + /** * Updates the `last_message_created_at` and `updated_at` timestamps for a session. * @param sessionId - The session identifier. @@ -275,9 +317,7 @@ export class SessionManager { runner_session_id: runnerSessionId, updated_at: Date.now(), }) - .where( - and(eq(sessions.id, sessionId), isNull(sessions.runner_session_id)), - ) + .where(eq(sessions.id, sessionId)) .run(); } diff --git a/src/plugins/codex-gated.ts b/src/plugins/codex-gated.ts deleted file mode 100644 index d2cb36e..0000000 --- a/src/plugins/codex-gated.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CodexAgentRunner } from "@/community/openai"; -import { registerRunner } from "@/kernel/agents"; -import { - config, - createLogger, - type AgentRunOptions, - type AgentRunner, - type AssistantMessage, - type SystemMessage, - type ToolMessage, - type UserMessage, -} from "@/shared"; - -import { detectCountry } from "./_country-check"; - -const _logger = createLogger("codex-gated"); - -/** - * Symmetric counterpart to {@link import("./claude-gated").default}: same - * country-gate + proxy-injection preamble, wrapping Codex instead of - * Claude. Codex already ships with `--dangerously-bypass-approvals-and-sandbox` - * baked in at the built-in runner level, so no extra CLI flag layering is - * needed here — the plugin's job is purely egress guarding. - * - * Enable by setting `agents.default.type: "codex-gated"` in `config.yaml`. - */ -class CodexGatedRunner implements AgentRunner { - readonly type = "codex-gated"; - private readonly _inner = new CodexAgentRunner(); - - async *stream( - message: UserMessage, - options: AgentRunOptions, - ): AsyncIterableIterator { - const proxy = _resolveProxy(); - - const country = await detectCountry({ proxy }); - if (country === null) { - throw new Error( - "无法判定当前出口 IP 所在国家/地区,已拦截 Codex 启动(codex-gated)。", - ); - } - if (country !== "US") { - throw new Error( - `检测到当前出口 IP 不在美国(country=${country}),已拦截 Codex 启动(codex-gated)。`, - ); - } - _logger.info({ country }, "country gate passed"); - - const mergedOptions: AgentRunOptions = proxy - ? { - ...options, - envExtras: { - ...(options.envExtras ?? {}), - HTTP_PROXY: proxy, - HTTPS_PROXY: proxy, - }, - } - : options; - - yield* this._inner.stream(message, mergedOptions); - } -} - -function _resolveProxy(): string | undefined { - const envMap = config.agents.env ?? {}; - return ( - envMap.HTTPS_PROXY ?? envMap.HTTP_PROXY ?? envMap.https_proxy ?? envMap.http_proxy - ); -} - -registerRunner("codex-gated", () => new CodexGatedRunner()); diff --git a/src/plugins/codex-yolo.ts b/src/plugins/codex-yolo.ts new file mode 100644 index 0000000..5604b84 --- /dev/null +++ b/src/plugins/codex-yolo.ts @@ -0,0 +1,46 @@ +import { CodexAgentRunner } from "@/community/openai"; +import { registerRunner } from "@/kernel/agents"; +import { + type AgentRunOptions, + type AgentRunner, + type AssistantMessage, + type SystemMessage, + type ToolMessage, + type UserMessage, +} from "@/shared"; + +const YOLO_PROXY = "http://127.0.0.1:7897"; + +/** + * Codex runner variant matching the local shell helper: + * + * HTTP_PROXY=http://127.0.0.1:7897 + * HTTPS_PROXY=http://127.0.0.1:7897 + * codex --search exec --dangerously-bypass-approvals-and-sandbox ... + * + * The base Codex runner already owns `exec`, JSON streaming, resume handling, + * and the dangerous bypass flag, so this plugin only injects the fixed proxy + * and the additional top-level `--search` CLI flag. + */ +class CodexYoloRunner implements AgentRunner { + readonly type = "codex-yolo"; + private readonly _inner = new CodexAgentRunner({ + extraGlobalArgs: ["--search"], + }); + + async *stream( + message: UserMessage, + options: AgentRunOptions, + ): AsyncIterableIterator { + yield* this._inner.stream(message, { + ...options, + envExtras: { + ...(options.envExtras ?? {}), + HTTP_PROXY: YOLO_PROXY, + HTTPS_PROXY: YOLO_PROXY, + }, + }); + } +} + +registerRunner("codex-yolo", () => new CodexYoloRunner()); diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 8c01ad6..adff1c8 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -9,4 +9,4 @@ * via `agents.default.type` in config.yaml. */ import "./claude-gated"; -import "./codex-gated"; +import "./codex-yolo"; diff --git a/src/shared/tasking/types/payload.ts b/src/shared/tasking/types/payload.ts index 1734cf8..c8ceffc 100644 --- a/src/shared/tasking/types/payload.ts +++ b/src/shared/tasking/types/payload.ts @@ -8,6 +8,7 @@ import { UserMessage } from "../../messaging"; export const InboundMessageTaskPayload = z.object({ type: z.literal("inbound_message"), message: UserMessage, + forceNewRunnerSession: z.boolean().optional(), }); export interface InboundMessageTaskPayload extends z.infer< typeof InboundMessageTaskPayload diff --git a/tests/community/openai/codex-agent-runner.test.ts b/tests/community/openai/codex-agent-runner.test.ts index bfa2f4f..a4dc81a 100644 --- a/tests/community/openai/codex-agent-runner.test.ts +++ b/tests/community/openai/codex-agent-runner.test.ts @@ -431,4 +431,35 @@ describe("CodexAgentRunner._parseStreamLine", () => { expect(args).toContain("--dangerously-bypass-approvals-and-sandbox"); expect(args).not.toContain("--full-auto"); }); + + test("inserts configured extra global args before exec", () => { + const runner = new CodexAgentRunner({ + extraGlobalArgs: ["--search"], + }) as unknown as Record; + const args = runner["_buildExecArgs"]!({ + isNew: true, + resumeId: "unused", + prompt: "\"hello\"", + }) as string[]; + + expect(args).toContain("--dangerously-bypass-approvals-and-sandbox"); + expect(args.slice(0, 3)).toEqual(["codex", "--search", "exec"]); + expect(args.at(-1)).toBe("\"hello\""); + }); + + test("detects missing Codex resume rollout errors", () => { + const runner = new CodexAgentRunner() as unknown as Record< + string, + CallableFunction + >; + + expect( + runner["_isMissingResumeErrorText"]!( + "Error: thread/resume: thread/resume failed: no rollout found for thread id 249fe9f1", + ), + ).toBe(true); + expect(runner["_isMissingResumeErrorText"]!("some other error")).toBe( + false, + ); + }); }); diff --git a/tests/kernel/agent-failure.test.ts b/tests/kernel/agent-failure.test.ts new file mode 100644 index 0000000..90f5b34 --- /dev/null +++ b/tests/kernel/agent-failure.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildAgentCancelledContent, + buildAgentFailureContent, +} from "@/kernel/agent-failure"; + +describe("buildAgentFailureContent", () => { + test("renders cancellation separately from failures", () => { + expect(buildAgentCancelledContent()).toEqual([ + { + type: "text", + text: "⏹️ 任务已取消。", + }, + ]); + }); + + test("renders runner startup failures as visible assistant text", () => { + const content = buildAgentFailureContent( + new Error("检测到当前出口 IP 不在美国(country=CN)"), + ); + + expect(content).toEqual([ + { + type: "text", + text: expect.stringContaining("Agent 启动或执行失败"), + }, + ]); + const first = content[0]!; + if (first.type !== "text") throw new Error("expected text content"); + expect(first.text).toContain("country=CN"); + expect(first.text).toContain("/agent use "); + }); + + test("falls back for non-Error throws", () => { + const content = buildAgentFailureContent("boom"); + + const first = content[0]!; + if (first.type !== "text") throw new Error("expected text content"); + expect(first.text).toContain("boom"); + }); +}); diff --git a/tests/kernel/agents/aliases.test.ts b/tests/kernel/agents/aliases.test.ts new file mode 100644 index 0000000..bb26ff0 --- /dev/null +++ b/tests/kernel/agents/aliases.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; + +import { + isLegacyAgentTypeAlias, + resolveAgentTypeAlias, +} from "@/kernel/agents"; + +describe("agent type aliases", () => { + test("maps removed codex-gated sessions to codex-yolo", () => { + expect(resolveAgentTypeAlias("codex-gated")).toBe("codex-yolo"); + expect(isLegacyAgentTypeAlias("codex-gated")).toBe(true); + }); + + test("leaves current runner types unchanged", () => { + expect(resolveAgentTypeAlias("codex-yolo")).toBe("codex-yolo"); + expect(resolveAgentTypeAlias("codex")).toBe("codex"); + expect(isLegacyAgentTypeAlias("codex-yolo")).toBe(false); + }); +}); diff --git a/tests/kernel/codex-resume-card.test.ts b/tests/kernel/codex-resume-card.test.ts new file mode 100644 index 0000000..6b68c5d --- /dev/null +++ b/tests/kernel/codex-resume-card.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildCodexResumeMissingCard, + CODEX_RESUME_RESTART_ACTION, + formatCodexResumeMissingText, +} from "@/kernel/codex-resume-card"; + +describe("codex resume recovery card", () => { + test("renders a restart callback button", () => { + const card = buildCodexResumeMissingCard({ resumeId: "thread-123" }); + const buttonRow = card.body.elements.at(-1); + + expect(card.config?.summary.content).toBe("Codex 续接失败"); + expect(JSON.stringify(card)).toContain("thread-123"); + expect(buttonRow).toMatchObject({ + tag: "column_set", + columns: [ + { + elements: [ + { + tag: "button", + text: { content: "重新开始 Codex 会话" }, + behaviors: [ + { + type: "callback", + value: { action: CODEX_RESUME_RESTART_ACTION }, + }, + ], + }, + ], + }, + ], + }); + }); + + test("text fallback tells users it will not auto restart", () => { + const text = formatCodexResumeMissingText("thread-123"); + + expect(text).toContain("thread-123"); + expect(text).toContain("不会自动重开"); + }); +}); From 9e3cdb6c18ec2d5da74f96a6782a4a2407978d2e Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 16:38:00 +0800 Subject: [PATCH 42/69] feat(kernel): add /setting panel and workspace deletion Aggregate scattered config commands into one interactive Feishu card: global defaults (agent / model / codex isolation / retries) edit in place and persist back to config.yaml, while a workspace list surfaces repo+branch, bindings, and last-active time with cascading delete. - add workspaces.last_active_at column (drizzle 0015, backfilled) - GroupWorkspaceStore: touchLastActive on resolve/bind, deleteWorkspace cascades tasks/sessions/bindings/dir, protects _default - SettingFlow orchestrates main/detail/confirm/result transitions via updateRawCard; actions share the setting_ prefix for routing - help text adds /setting and /workspaces entries --- bun.lock | 5 +- drizzle/0015_fuzzy_gressill.sql | 2 + drizzle/meta/0015_snapshot.json | 422 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 3 +- src/kernel/commands/handlers.ts | 2 + src/kernel/kernel.ts | 23 + src/kernel/sessioning/data/schema.ts | 7 + src/kernel/setting/config-writer.ts | 87 ++++ src/kernel/setting/setting-card.ts | 493 +++++++++++++++++++++ src/kernel/setting/setting-flow.ts | 502 ++++++++++++++++++++++ src/kernel/workspaces/store.ts | 135 +++++- src/shared/workspaces/types/workspace.ts | 2 + tests/kernel/setting/fixtures.ts | 32 ++ tests/kernel/setting/setting-card.test.ts | 158 +++++++ tests/kernel/setup/cards.test.ts | 1 + tests/kernel/workspaces/delete.test.ts | 206 +++++++++ 17 files changed, 2082 insertions(+), 5 deletions(-) create mode 100644 drizzle/0015_fuzzy_gressill.sql create mode 100644 drizzle/meta/0015_snapshot.json create mode 100644 src/kernel/setting/config-writer.ts create mode 100644 src/kernel/setting/setting-card.ts create mode 100644 src/kernel/setting/setting-flow.ts create mode 100644 tests/kernel/setting/fixtures.ts create mode 100644 tests/kernel/setting/setting-card.test.ts create mode 100644 tests/kernel/workspaces/delete.test.ts diff --git a/bun.lock b/bun.lock index 47a60c3..ea384a5 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "uuid": "^13.0.0", + "yaml": "^2.8.3", }, "devDependencies": { "@eslint/js": "^10.0.0", @@ -760,7 +761,7 @@ "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.2", "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.8.3", "https://bnpm.byted.org/yaml/-/yaml-2.8.3.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -796,6 +797,8 @@ "fdir/picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "lint-staged/yaml": ["yaml@2.8.2", "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "send/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], diff --git a/drizzle/0015_fuzzy_gressill.sql b/drizzle/0015_fuzzy_gressill.sql new file mode 100644 index 0000000..7bf9c49 --- /dev/null +++ b/drizzle/0015_fuzzy_gressill.sql @@ -0,0 +1,2 @@ +ALTER TABLE `workspaces` ADD `last_active_at` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +UPDATE `workspaces` SET `last_active_at` = `updated_at`; diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..faa3e8f --- /dev/null +++ b/drizzle/meta/0015_snapshot.json @@ -0,0 +1,422 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1c0c058a-c700-4e8a-b925-a2cd0e6f57dd", + "prevId": "53369f63-7a3f-4ee0-ad17-377fad384943", + "tables": { + "group_workspaces": { + "name": "group_workspaces", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_message": { + "name": "first_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "runner_session_id": { + "name": "runner_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_repo": { + "name": "active_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_branch": { + "name": "active_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_path_unique": { + "name": "workspaces_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_session_id": { + "name": "idx_tasks_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_bot_groups": { + "name": "feishu_bot_groups", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_open_id": { + "name": "creator_open_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feishu_threads": { + "name": "feishu_threads", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_respond": { + "name": "auto_respond", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 68c503d..4308c88 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1776842966838, "tag": "0014_closed_loki", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1776932708363, + "tag": "0015_fuzzy_gressill", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index d6478ca..9623a96 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "hono": "^4.12.5", "pino": "^10.3.1", "pino-pretty": "^13.1.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "yaml": "^2.8.3" } } \ No newline at end of file diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index f7d3a2e..e606580 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -646,6 +646,8 @@ export const helpHandler: CommandHandler = { ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), "- /help — 显示本消息", "- /stop — 取消当前 session 正在执行的任务", + "- /setting — 打开设置面板(全局配置 + workspace 管理)", + "- /workspaces — 打开设置面板(快捷入口,等价于 /setting)", "- /setup — 打开 workspace 配置卡片(仅群聊)", "- /switch — 打开 workspace 切换卡片(群聊 & 单聊)", "- /group <群名> @user... — 机器人建群并自动 /setup(仅单聊)", diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index c42e9ed..8fdbd21 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -48,6 +48,7 @@ import { MultiChannelMessageGateway } from "./messaging"; import { PERMISSION_ACTION, PermissionFlow } from "./permission"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; +import { SettingFlow } from "./setting/setting-flow"; import { SetupFlow } from "./setup/setup-flow"; import { SwitchFlow } from "./setup/switch-flow"; import { TaskDispatcher } from "./tasking"; @@ -70,6 +71,7 @@ class Kernel { private _feishuChannels = new Map(); private _setupFlow!: SetupFlow; private _switchFlow!: SwitchFlow; + private _settingFlow!: SettingFlow; private _groupFlow!: GroupFlow; private _permissionFlow!: PermissionFlow; private _codexResumeRestarts = new Map< @@ -86,6 +88,7 @@ class Kernel { this._initMessageGateway(); this._initSetupFlow(); this._initSwitchFlow(); + this._initSettingFlow(); this._initGroupFlow(); this._initPermissionFlow(); this._initServer(); @@ -209,6 +212,14 @@ class Kernel { }); } + private _initSettingFlow(): void { + this._settingFlow = new SettingFlow({ + workspaceStore: this._workspaceStore, + feishuChannels: this._feishuChannels, + db: this._database.db, + }); + } + private _initPermissionFlow(): void { this._permissionFlow = new PermissionFlow({ feishuChannels: this._feishuChannels, @@ -284,6 +295,14 @@ class Kernel { return; } + // Handle /setting + /workspaces commands (kernel-owned — interactive + // panel). Both open the same main card; the name duplication is just a + // convenience shortcut for users who only want the workspace view. + if (text === "/setting" || text === "/workspaces") { + await this._settingFlow.start(message); + return; + } + // Handle /group command (kernel-owned — orchestrates create-chat + // transfer-owner + auto /setup). Takes args, so match the prefix rather // than equality. @@ -562,6 +581,10 @@ class Kernel { await this._handleCodexResumeRestart(payload); return; } + if (payload.action_name.startsWith("setting_")) { + await this._settingFlow.handleAction(payload); + return; + } this._logger.warn( { action_name: payload.action_name, message_id: payload.message_id }, "unhandled card action", diff --git a/src/kernel/sessioning/data/schema.ts b/src/kernel/sessioning/data/schema.ts index be1555c..4d62d36 100644 --- a/src/kernel/sessioning/data/schema.ts +++ b/src/kernel/sessioning/data/schema.ts @@ -57,6 +57,13 @@ export const workspaces = sqliteTable( created_at: integer("created_at").notNull(), /** Epoch milliseconds when the workspace was last updated. */ updated_at: integer("updated_at").notNull(), + /** + * Epoch milliseconds of the last time any chat bound to this workspace + * dispatched a message, re-bound, or mutated active state. Distinct from + * `updated_at` (which only moves on explicit row writes): this tracks + * usage, so the `/setting` panel can surface dormant workspaces. + */ + last_active_at: integer("last_active_at").notNull(), }, (table) => ({ path_unique: uniqueIndex("workspaces_path_unique").on(table.path), diff --git a/src/kernel/setting/config-writer.ts b/src/kernel/setting/config-writer.ts new file mode 100644 index 0000000..f448c3b --- /dev/null +++ b/src/kernel/setting/config-writer.ts @@ -0,0 +1,87 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +import { config, createLogger, reloadConfig } from "@/shared"; + +const _logger = createLogger("config-writer"); + +/** + * Partial shape of the editable slice of `config.yaml`. Kept deliberately + * narrow — only the fields exposed by the `/setting` panel land here. Fields + * not present in the patch are left untouched on disk. + */ +export interface SettingConfigPatch { + timezone?: string; + agents?: { + default?: { + type?: string; + /** Empty string clears the key so the runner uses its own default. */ + model?: string; + }; + codex?: { + isolate_host_env?: boolean; + }; + }; + tasking?: { + max_retries?: number; + }; +} + +/** + * Read `$AGENTARA_HOME/config.yaml`, deep-merge the patch, write it back, + * then reload the in-memory `config` singleton. Throws on YAML/IO errors — + * callers should surface the failure on the result card. + */ +export function writeConfigPatch(patch: SettingConfigPatch): void { + const path = join(config.paths.home, "config.yaml"); + const raw = readFileSync(path, "utf-8"); + const parsed = (parseYaml(raw) as Record | null) ?? {}; + const merged = _deepMerge(parsed, patch as Record); + _applyClears(merged); + writeFileSync(path, stringifyYaml(merged)); + reloadConfig(); + _logger.info({ patch }, "config.yaml updated"); +} + +function _isPlainObject(v: unknown): v is Record { + return ( + typeof v === "object" && + v !== null && + !Array.isArray(v) && + Object.getPrototypeOf(v) === Object.prototype + ); +} + +function _deepMerge( + base: Record, + patch: Record, +): Record { + const out: Record = { ...base }; + for (const [key, patchVal] of Object.entries(patch)) { + if (patchVal === undefined) continue; + const baseVal = out[key]; + if (_isPlainObject(baseVal) && _isPlainObject(patchVal)) { + out[key] = _deepMerge(baseVal, patchVal); + } else { + out[key] = patchVal; + } + } + return out; +} + +/** + * Empty-string sentinel means "clear the field" — `agents.default.model` is + * optional in the Zod schema, and forcing an empty string through would break + * runners that treat "" as a real model id. + */ +function _applyClears(merged: Record): void { + const agents = merged.agents; + if (_isPlainObject(agents)) { + const def = agents.default; + if (_isPlainObject(def) && def.model === "") { + delete def.model; + } + } +} diff --git a/src/kernel/setting/setting-card.ts b/src/kernel/setting/setting-card.ts new file mode 100644 index 0000000..563fc66 --- /dev/null +++ b/src/kernel/setting/setting-card.ts @@ -0,0 +1,493 @@ +import dayjs from "dayjs"; + +import { formatRepoRef } from "@/kernel/repo-ref"; +import type { GroupWorkspace, Workspace } from "@/shared"; + +import type { + ButtonElement, + Card, + CheckerElement, + Element, + FormElement, + InputElement, + SelectStaticElement, +} from "../../community/feishu/messaging/types"; +import { + buildCardIntro, + buildMarkdown, + buildResultCard, + buildSectionBlock, +} from "../setup/card-ui"; + +/** + * Action discriminators for every interactive element on `/setting` cards. + * `setting_save_config` is a form_submit button (Feishu echoes the button + * name as `action_name`); the rest are callback buttons whose `value.action` + * drives routing. All names share the `setting_` prefix so the kernel can + * forward them to the flow with a single `startsWith` check. + */ +export const SETTING_ACTION = { + saveConfig: "setting_save_config", + mainBack: "setting_main_back", + wsDetail: "setting_ws_detail", + wsDeletePrompt: "setting_ws_delete_prompt", + wsDeleteApply: "setting_ws_delete_apply", +} as const; + +/** + * Form field names for the global-config section of the main card. Shared + * between renderer and submit handler so the two cannot drift. + */ +export const SETTING_FIELD = { + agentType: "agent_type", + agentModel: "agent_model", + codexIsolateHostEnv: "codex_isolate_host_env", + maxRetries: "max_retries", +} as const; + +/** + * Snapshot of everything the main card needs to render. Flow resolves these + * once per render and passes them in; the card file stays pure. + */ +export interface SettingMainCardOptions { + agent: { + active_type: string; + available_types: string[]; + }; + config_values: { + agent_model: string; + codex_isolate_host_env: boolean; + max_retries: number; + }; + workspaces: Array<{ + workspace: Workspace; + binding_count: number; + is_current: boolean; + }>; + current_chat_id?: string | null; +} + +/** + * Top-level `/setting` panel: a global-config form on top, a workspace list + * beneath. Each workspace row has a "详情" callback button that swaps the + * card for a detail view in place via `updateRawCard`. + */ +export function buildSettingMainCard(options: SettingMainCardOptions): Card { + const elements: Element[] = [ + buildCardIntro({ + title: "设置面板", + subtitle: "全局运行时 + workspace 管理", + }), + _buildConfigForm(options), + buildMarkdown("**Workspace 管理**"), + ]; + + if (options.workspaces.length === 0) { + elements.push( + buildMarkdown("还没有任何 workspace。在群里执行 `/setup` 创建一个。"), + ); + } else { + for (const entry of options.workspaces) { + elements.push(_buildWorkspaceRow(entry)); + } + } + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: "⚙️ 设置面板" }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +/** + * Detail card for one workspace: metadata, cloned repos + branches, list of + * bound chats, danger button that leads to the delete-confirm card. + */ +export interface WorkspaceDetailCardOptions { + workspace: Workspace; + bindings: GroupWorkspace[]; + repos: Array<{ name: string; branch: string | null; is_active: boolean }>; + is_protected: boolean; +} + +export function buildWorkspaceDetailCard( + options: WorkspaceDetailCardOptions, +): Card { + const { workspace, bindings, repos, is_protected } = options; + const activeRepoLine = workspace.active_repo + ? `\`${formatRepoRef(workspace.active_repo, workspace.active_branch ?? "")}\`` + : "(未设置)"; + + const elements: Element[] = [ + buildCardIntro({ + title: `Workspace: ${workspace.name}`, + subtitle: workspace.id, + }), + ...buildSectionBlock({ + title: "基本信息", + lines: [ + `- 路径:\`${workspace.path}\``, + `- 活跃主仓库:${activeRepoLine}`, + `- 创建时间:${_formatTs(workspace.created_at)}`, + `- 上次活跃:${_formatTs(workspace.last_active_at)} (${_formatRelative(workspace.last_active_at)})`, + ], + }), + ...buildSectionBlock({ + title: `仓库与分支 (${repos.length})`, + lines: + repos.length === 0 + ? ["- (当前 workspace 下还没有任何仓库)"] + : repos.map((r) => { + const ref = formatRepoRef(r.name, r.branch ?? "(游离)"); + return `- \`${ref}\`${r.is_active ? " ← 活跃" : ""}`; + }), + }), + ...buildSectionBlock({ + title: `绑定的群 (${bindings.length})`, + lines: + bindings.length === 0 + ? ["- (没有群绑定到此 workspace)"] + : bindings.map((b) => `- \`${b.chat_id}\``), + }), + _buildDetailActionRow(workspace.id, is_protected), + ]; + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: `🗂️ ${workspace.name}` }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +/** + * Red two-button confirm card shown before anything is actually removed. + * The apply button is the only path that triggers cascading deletion. + */ +export interface WorkspaceDeleteConfirmCardOptions { + workspace: Workspace; + binding_count: number; + estimated_session_count: number; +} + +export function buildWorkspaceDeleteConfirmCard( + options: WorkspaceDeleteConfirmCardOptions, +): Card { + const { workspace, binding_count, estimated_session_count } = options; + const elements: Element[] = [ + buildCardIntro({ + title: "⚠️ 确认删除 Workspace", + subtitle: workspace.name, + }), + buildMarkdown( + [ + "此操作不可撤销,会执行以下清理:", + `- 解绑 **${binding_count}** 个群`, + `- 级联删除约 **${estimated_session_count}** 个关联 session 及其 task 记录`, + `- 移除物理目录 \`${workspace.path}\``, + "- 保留 `git-cache/`(其他 workspace 仍可复用)", + ].join("\n"), + ), + _buildDeleteConfirmRow(workspace.id), + ]; + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: `⚠️ 确认删除 ${workspace.name}` }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +/** + * Terminal result card reused by every setting flow (save / delete). Thin + * wrapper over the shared `buildResultCard` so we can tweak the title in + * one place if needed later. + */ +export function buildSettingResultCard( + summary: string, + detail: string[] = [], +): Card { + return buildResultCard({ + title: "设置面板", + summary, + detail, + }); +} + +function _buildConfigForm(options: SettingMainCardOptions): FormElement { + const agentSelect: SelectStaticElement = { + tag: "select_static", + name: SETTING_FIELD.agentType, + placeholder: { tag: "plain_text", content: "选择默认 Agent" }, + initial_option: options.agent.available_types.includes( + options.agent.active_type, + ) + ? options.agent.active_type + : options.agent.available_types[0], + options: options.agent.available_types.map((t) => ({ + text: { tag: "plain_text", content: t }, + value: t, + })), + width: "fill", + }; + + const modelInput: InputElement = { + tag: "input", + name: SETTING_FIELD.agentModel, + placeholder: { + tag: "plain_text", + content: "留空表示让 runner 自选(如 Claude Code 默认)", + }, + default_value: options.config_values.agent_model, + width: "fill", + }; + + const codexChecker: CheckerElement = { + tag: "checker", + name: SETTING_FIELD.codexIsolateHostEnv, + text: { + tag: "plain_text", + content: "Codex 环境隔离(指向独立 CODEX_HOME)", + }, + checked: options.config_values.codex_isolate_host_env, + }; + + const retriesInput: InputElement = { + tag: "input", + name: SETTING_FIELD.maxRetries, + placeholder: { tag: "plain_text", content: "正整数,如 3" }, + default_value: String(options.config_values.max_retries), + width: "fill", + }; + + const submitBtn: ButtonElement = { + tag: "button", + name: SETTING_ACTION.saveConfig, + text: { tag: "plain_text", content: "保存配置" }, + type: "primary", + action_type: "form_submit", + width: "fill", + }; + + return { + tag: "form", + name: "setting_config_form", + elements: [ + buildMarkdown("**全局配置**"), + buildMarkdown("默认 Agent", { + text_size: "notation", + }), + agentSelect, + buildMarkdown("Agent Model", { + text_size: "notation", + }), + modelInput, + buildMarkdown("Codex 行为", { + text_size: "notation", + }), + codexChecker, + buildMarkdown("任务重试上限", { + text_size: "notation", + }), + retriesInput, + submitBtn, + ], + }; +} + +function _buildWorkspaceRow(entry: { + workspace: Workspace; + binding_count: number; + is_current: boolean; +}): Element { + const { workspace, binding_count, is_current } = entry; + const activeLine = workspace.active_repo + ? `\`${formatRepoRef(workspace.active_repo, workspace.active_branch ?? "")}\`` + : "(未设置主仓库)"; + const marks: string[] = []; + if (is_current) marks.push("当前群"); + if (binding_count > 0) marks.push(`${binding_count} 群绑定`); + const summaryLine = + `- **${workspace.name}** \`${workspace.id}\` ` + + `${activeLine} · 活跃 ${_formatRelative(workspace.last_active_at)}` + + (marks.length ? ` · ${marks.join(" / ")}` : ""); + + const detailBtn: ButtonElement = { + tag: "button", + name: "setting_ws_detail_btn", + text: { tag: "plain_text", content: "详情" }, + type: "default", + behaviors: [ + { + type: "callback", + value: { + action: SETTING_ACTION.wsDetail, + workspace_id: workspace.id, + }, + }, + ], + }; + + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { + tag: "column", + width: "weighted", + weight: 3, + elements: [buildMarkdown(summaryLine)], + }, + { + tag: "column", + width: "80px", + elements: [detailBtn], + }, + ], + }; +} + +function _buildDetailActionRow( + workspaceId: string, + isProtected: boolean, +): Element { + const backBtn: ButtonElement = { + tag: "button", + name: "setting_main_back_btn", + text: { tag: "plain_text", content: "← 返回" }, + type: "default", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: SETTING_ACTION.mainBack }, + }, + ], + }; + if (isProtected) { + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { + tag: "column", + width: "weighted", + weight: 1, + elements: [backBtn], + }, + ], + }; + } + const deleteBtn: ButtonElement = { + tag: "button", + name: "setting_ws_delete_prompt_btn", + text: { tag: "plain_text", content: "删除 Workspace" }, + type: "danger", + width: "fill", + behaviors: [ + { + type: "callback", + value: { + action: SETTING_ACTION.wsDeletePrompt, + workspace_id: workspaceId, + }, + }, + ], + }; + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [backBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [deleteBtn] }, + ], + }; +} + +function _buildDeleteConfirmRow(workspaceId: string): Element { + const cancelBtn: ButtonElement = { + tag: "button", + name: "setting_delete_cancel_btn", + text: { tag: "plain_text", content: "取消" }, + type: "default", + width: "fill", + behaviors: [ + { + type: "callback", + value: { + action: SETTING_ACTION.wsDetail, + workspace_id: workspaceId, + }, + }, + ], + }; + const confirmBtn: ButtonElement = { + tag: "button", + name: "setting_delete_apply_btn", + text: { tag: "plain_text", content: "确认删除" }, + type: "danger", + width: "fill", + behaviors: [ + { + type: "callback", + value: { + action: SETTING_ACTION.wsDeleteApply, + workspace_id: workspaceId, + }, + }, + ], + }; + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [cancelBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [confirmBtn] }, + ], + }; +} + +function _formatTs(ms: number): string { + if (!ms) return "(未知)"; + return dayjs(ms).format("YYYY-MM-DD HH:mm"); +} + +function _formatRelative(ms: number): string { + if (!ms) return "未知"; + const diff = Date.now() - ms; + if (diff < 60_000) return "刚刚"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`; + if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`; + return dayjs(ms).format("YYYY-MM-DD"); +} diff --git a/src/kernel/setting/setting-flow.ts b/src/kernel/setting/setting-flow.ts new file mode 100644 index 0000000..53d2c51 --- /dev/null +++ b/src/kernel/setting/setting-flow.ts @@ -0,0 +1,502 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { eq, like, or } from "drizzle-orm"; + +import type { DrizzleDB } from "@/data"; +import { + getAgentRuntimeState, + listRunnerTypes, + setRuntimeDefaultAgentType, + UnknownAgentTypeError, +} from "@/kernel/agents"; +import { sessions } from "@/kernel/sessioning/data"; +import { + readRepoHead, + WorkspaceNotFoundError, + WorkspaceProtectedError, + type GroupWorkspaceStore, + type WorkspaceDeleteResult, +} from "@/kernel/workspaces"; +import type { GroupWorkspace, Logger, Workspace } from "@/shared"; +import { config, createLogger, type CardActionPayload, type UserMessage } from "@/shared"; + +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; +import type { Card } from "../../community/feishu/messaging/types"; + +import { writeConfigPatch, type SettingConfigPatch } from "./config-writer"; +import { + buildSettingMainCard, + buildSettingResultCard, + buildWorkspaceDeleteConfirmCard, + buildWorkspaceDetailCard, + SETTING_ACTION, + SETTING_FIELD, +} from "./setting-card"; + +/** + * Stateful orchestrator for the `/setting` panel. + * + * Lifecycle: + * 1. `start(message)` renders the main panel (global config form + workspace + * list) into the chat and returns. + * 2. Every subsequent click routes back through `handleAction(payload)`, + * which replaces the card in place (`updateRawCard`) with a detail view, + * a delete-confirm view, or a terminal result. + * + * Unlike `/setup`/`/switch`, we intentionally don't track pending cards in + * an in-memory map: every action payload carries the `workspace_id` it + * applies to, so we can operate statelessly. A stale card from before a + * restart still works — the worst case is pointing at a workspace that has + * since been deleted, which the flow handles gracefully. + */ +export class SettingFlow { + private readonly _logger: Logger = createLogger("setting-flow"); + private readonly _workspaceStore: GroupWorkspaceStore; + private readonly _feishuChannels: Map; + private readonly _db: DrizzleDB; + + constructor(deps: { + workspaceStore: GroupWorkspaceStore; + feishuChannels: Map; + db: DrizzleDB; + }) { + this._workspaceStore = deps.workspaceStore; + this._feishuChannels = deps.feishuChannels; + this._db = deps.db; + } + + /** Entry point for `/setting` and `/workspaces`. */ + async start(message: UserMessage): Promise { + const chatId = message.chat_id; + if (!chatId || !message.channel_id) { + await this._replyText(message, "❌ /setting 需要飞书会话上下文。"); + return; + } + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) { + await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); + return; + } + try { + const card = this._renderMainCard(chatId); + await channel.sendRawCard(chatId, card, { + replyTo: message.id, + replyInThread: false, + }); + } catch (err) { + this._logger.error({ err, chat_id: chatId }, "failed to render setting card"); + await this._replyText( + message, + `❌ 渲染设置面板失败:${(err as Error).message}`, + ); + } + } + + /** Single entry point for every `setting_*` card action. */ + async handleAction(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id, action_name: payload.action_name }, + "setting action for unknown channel", + ); + return; + } + try { + switch (payload.action_name) { + case SETTING_ACTION.saveConfig: + await this._handleSaveConfig(channel, payload); + return; + case SETTING_ACTION.mainBack: + await this._handleMainBack(channel, payload); + return; + case SETTING_ACTION.wsDetail: + await this._handleWsDetail(channel, payload); + return; + case SETTING_ACTION.wsDeletePrompt: + await this._handleWsDeletePrompt(channel, payload); + return; + case SETTING_ACTION.wsDeleteApply: + await this._handleWsDeleteApply(channel, payload); + return; + default: + this._logger.warn( + { action_name: payload.action_name }, + "unknown setting action", + ); + } + } catch (err) { + this._logger.error( + { err, action_name: payload.action_name }, + "setting action failed", + ); + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard(`❌ 操作失败:${(err as Error).message}`), + "action-error", + ); + } + } + + private async _handleSaveConfig( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const raw = payload.form_value; + const newAgentType = + typeof raw[SETTING_FIELD.agentType] === "string" + ? (raw[SETTING_FIELD.agentType] as string).trim() + : ""; + const newAgentModel = + typeof raw[SETTING_FIELD.agentModel] === "string" + ? (raw[SETTING_FIELD.agentModel] as string).trim() + : ""; + const newCodexIsolate = _coerceBool(raw[SETTING_FIELD.codexIsolateHostEnv]); + const parsedRetries = _coerceInt(raw[SETTING_FIELD.maxRetries]); + + if (parsedRetries === null || parsedRetries <= 0) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard( + "❌ 任务重试上限必须为正整数,未保存任何改动。", + ), + "invalid-retries", + ); + return; + } + + const patch: SettingConfigPatch = { + agents: { + default: { + type: newAgentType || undefined, + model: newAgentModel, + }, + codex: { + isolate_host_env: newCodexIsolate, + }, + }, + tasking: { + max_retries: parsedRetries, + }, + }; + + const appliedLines: string[] = []; + try { + writeConfigPatch(patch); + appliedLines.push( + `- 默认 Agent(配置):\`${newAgentType || "(未变)"}\``, + `- Agent Model:\`${newAgentModel || "(未设置)"}\``, + `- Codex 环境隔离:${newCodexIsolate ? "✅ 开启" : "⬜ 关闭"}`, + `- 任务重试上限:\`${parsedRetries}\``, + ); + } catch (err) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard( + `❌ 写入 config.yaml 失败:${(err as Error).message}`, + ), + "write-failed", + ); + return; + } + + // Keep the runtime override in sync with the configured default when the + // user picks a new Agent. Any invalid type throws — we surface it but + // still keep the file-level change, since the YAML write already landed. + if (newAgentType) { + try { + const result = setRuntimeDefaultAgentType(newAgentType); + appliedLines.push( + result.changed + ? `- 运行时默认 Agent:\`${result.previousType}\` → \`${result.currentType}\`` + : `- 运行时默认 Agent:\`${result.currentType}\`(未变)`, + ); + } catch (err) { + if (err instanceof UnknownAgentTypeError) { + appliedLines.push( + `- ⚠️ 运行时 Agent 未切换:\`${err.type}\` 不在可用列表 [${err.availableTypes.join(", ")}]`, + ); + } else { + throw err; + } + } + } + + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard("✅ 配置已保存。", appliedLines), + "save-ok", + ); + } + + private async _handleMainBack( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const card = this._renderMainCard(payload.chat_id ?? null); + await this._tryUpdateCard(channel, payload.message_id, card, "main-back"); + } + + private async _handleWsDetail( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const workspaceId = _readWorkspaceId(payload); + if (!workspaceId) return; + const workspace = this._workspaceStore.getWorkspace(workspaceId); + if (!workspace) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard("⚠️ workspace 已不存在,请重新发送 `/setting`。"), + "ws-missing", + ); + return; + } + const bindings = this._workspaceStore + .listBindings() + .filter((b) => b.workspace_id === workspaceId); + const repos = _scanRepos(workspace); + await this._tryUpdateCard( + channel, + payload.message_id, + buildWorkspaceDetailCard({ + workspace, + bindings, + repos, + is_protected: workspace.path === config.paths.default_workspace, + }), + "ws-detail", + ); + } + + private async _handleWsDeletePrompt( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const workspaceId = _readWorkspaceId(payload); + if (!workspaceId) return; + const workspace = this._workspaceStore.getWorkspace(workspaceId); + if (!workspace) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard("⚠️ workspace 已不存在。"), + "ws-missing", + ); + return; + } + const bindingCount = this._workspaceStore + .listBindings() + .filter((b) => b.workspace_id === workspaceId).length; + const sessionCount = this._countSessionsInWorkspace(workspace.path); + await this._tryUpdateCard( + channel, + payload.message_id, + buildWorkspaceDeleteConfirmCard({ + workspace, + binding_count: bindingCount, + estimated_session_count: sessionCount, + }), + "ws-delete-prompt", + ); + } + + private async _handleWsDeleteApply( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const workspaceId = _readWorkspaceId(payload); + if (!workspaceId) return; + let result: WorkspaceDeleteResult; + try { + result = this._workspaceStore.deleteWorkspace(workspaceId); + } catch (err) { + if (err instanceof WorkspaceNotFoundError) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard("⚠️ workspace 已不存在。"), + "ws-missing", + ); + return; + } + if (err instanceof WorkspaceProtectedError) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard("🚫 默认 workspace 受保护,无法删除。"), + "ws-protected", + ); + return; + } + throw err; + } + const lines = [ + `- 名称:\`${result.workspace_name}\``, + `- ID:\`${result.workspace_id}\``, + `- 已解绑 **${result.removed_bindings}** 个群`, + `- 已级联删除 **${result.removed_sessions}** 个 session,**${result.removed_tasks}** 条 task 记录`, + result.removed_directory + ? "- 已删除物理目录" + : "- ⚠️ 物理目录未能删除(可能已不存在或权限问题),详见日志", + ]; + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard( + `✅ 已删除 workspace \`${result.workspace_name}\`。`, + lines, + ), + "ws-delete-apply", + ); + } + + private _renderMainCard(currentChatId: string | null): Card { + const agentState = getAgentRuntimeState(); + const availableTypes = + agentState.availableTypes.length > 0 + ? agentState.availableTypes + : listRunnerTypes(); + const workspaces = this._workspaceStore.listWorkspaces(); + const bindings = this._workspaceStore.listBindings(); + const bindingCounts = new Map(); + for (const b of bindings) { + bindingCounts.set( + b.workspace_id, + (bindingCounts.get(b.workspace_id) ?? 0) + 1, + ); + } + const currentBinding = currentChatId + ? bindings.find((b) => b.chat_id === currentChatId) + : undefined; + const sortedWorkspaces = [...workspaces].sort( + (a, b) => b.last_active_at - a.last_active_at, + ); + return buildSettingMainCard({ + agent: { + active_type: agentState.activeType, + available_types: availableTypes, + }, + config_values: { + agent_model: config.agents.default.model ?? "", + codex_isolate_host_env: config.agents.codex.isolate_host_env, + max_retries: config.tasking.max_retries, + }, + workspaces: sortedWorkspaces.map((ws) => ({ + workspace: ws, + binding_count: bindingCounts.get(ws.id) ?? 0, + is_current: currentBinding?.workspace_id === ws.id, + })), + current_chat_id: currentChatId, + }); + } + + private _countSessionsInWorkspace(workspacePath: string): number { + const rows = this._db + .select({ id: sessions.id }) + .from(sessions) + .where( + or( + eq(sessions.cwd, workspacePath), + like(sessions.cwd, `${workspacePath}/%`), + ), + ) + .all(); + return rows.length; + } + + private async _tryUpdateCard( + channel: FeishuMessageChannel, + messageId: string, + card: Card, + stage: string, + ): Promise { + try { + await channel.updateRawCard(messageId, card); + } catch (err) { + this._logger.error( + { err, stage, message_id: messageId }, + "setting updateRawCard failed", + ); + } + } + + private async _replyText(message: UserMessage, text: string): Promise { + if (!message.channel_id) return; + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) return; + await channel.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }, + { streaming: false, replyInThread: false }, + ); + } +} + +function _readWorkspaceId(payload: CardActionPayload): string | null { + const wsId = payload.value.workspace_id; + return typeof wsId === "string" && wsId ? wsId : null; +} + +function _coerceBool(v: unknown): boolean { + if (typeof v === "boolean") return v; + if (typeof v === "string") { + const lower = v.toLowerCase(); + return lower === "true" || lower === "1" || lower === "on"; + } + return false; +} + +function _coerceInt(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return Math.trunc(v); + if (typeof v === "string") { + const parsed = parseInt(v.trim(), 10); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +interface RepoSummary { + name: string; + branch: string | null; + is_active: boolean; +} + +function _scanRepos(workspace: Workspace): RepoSummary[] { + let entries: string[]; + try { + entries = readdirSync(workspace.path, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !name.startsWith(".")); + } catch { + return []; + } + const repos: RepoSummary[] = []; + for (const name of entries) { + const repoPath = join(workspace.path, name); + if (!existsSync(join(repoPath, ".git"))) continue; + const head = readRepoHead(repoPath); + repos.push({ + name, + branch: head ?? null, + is_active: name === workspace.active_repo, + }); + } + return repos.sort((a, b) => a.name.localeCompare(b.name)); +} + +// Expose GroupWorkspace so `_handleWsDetail` callers don't need to import from +// deep paths — not used today but keeps the barrel clean if we ever surface +// binding details publicly. +export type { GroupWorkspace }; diff --git a/src/kernel/workspaces/store.ts b/src/kernel/workspaces/store.ts index df4d214..d06cfcd 100644 --- a/src/kernel/workspaces/store.ts +++ b/src/kernel/workspaces/store.ts @@ -1,11 +1,12 @@ -import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import dayjs from "dayjs"; -import { eq } from "drizzle-orm"; +import { eq, inArray, like, or } from "drizzle-orm"; import type { DrizzleDB } from "@/data"; -import { groupWorkspaces, workspaces } from "@/kernel/sessioning/data"; +import { groupWorkspaces, sessions, workspaces } from "@/kernel/sessioning/data"; +import { tasks } from "@/kernel/tasking/data"; import { config, createLogger, uuid, type GroupWorkspace, type Workspace } from "@/shared"; import { readRepoHead } from "./git-sync"; @@ -154,6 +155,7 @@ export class GroupWorkspaceStore { updated_at: now, }) .run(); + this.touchLastActive(workspace.id, now); this._writeMetaFile(workspace); return { chat_id: chatId, @@ -175,6 +177,7 @@ export class GroupWorkspaceStore { }) .where(eq(groupWorkspaces.chat_id, chatId)) .run(); + this.touchLastActive(workspace.id, now); this._writeMetaFile(workspace); return { ...existing, @@ -226,6 +229,7 @@ export class GroupWorkspaceStore { if (!existsSync(binding.workspace_path)) { mkdirSync(binding.workspace_path, { recursive: true }); } + this.touchLastActive(binding.workspace_id); const envExtras: Record = {}; if (binding.active_repo) { envExtras.DEV_ASSETS_PRIMARY_REPO = binding.active_repo; @@ -243,6 +247,97 @@ export class GroupWorkspaceStore { }; } + /** + * Bump `workspaces.last_active_at` for the given workspace. Cheap update — + * leaves `updated_at` alone so the two columns stay semantically distinct + * (`updated_at` = row mutated, `last_active_at` = workspace was used). + */ + touchLastActive(workspaceId: string, ts: number = Date.now()): void { + this._db + .update(workspaces) + .set({ last_active_at: ts }) + .where(eq(workspaces.id, workspaceId)) + .run(); + } + + /** + * Fully remove a workspace: tasks → sessions → bindings → row → on-disk + * directory. The shared git-cache at `$AGENTARA_HOME/git-cache/` is left + * intact so other workspaces keep benefiting from it. Refuses to touch + * the reserved `_default` fallback directory. + */ + deleteWorkspace(workspaceId: string): WorkspaceDeleteResult { + const workspace = this.getWorkspace(workspaceId); + if (!workspace) { + throw new WorkspaceNotFoundError(workspaceId); + } + if (workspace.path === config.paths.default_workspace) { + throw new WorkspaceProtectedError(workspaceId); + } + + const sessionRows = this._db + .select({ id: sessions.id }) + .from(sessions) + .where( + or( + eq(sessions.cwd, workspace.path), + like(sessions.cwd, `${workspace.path}/%`), + ), + ) + .all(); + const sessionIds = sessionRows.map((r) => r.id); + + let removedTasks = 0; + if (sessionIds.length > 0) { + const taskRows = this._db + .select({ id: tasks.id }) + .from(tasks) + .where(inArray(tasks.session_id, sessionIds)) + .all(); + removedTasks = taskRows.length; + if (removedTasks > 0) { + this._db.delete(tasks).where(inArray(tasks.session_id, sessionIds)).run(); + } + this._db.delete(sessions).where(inArray(sessions.id, sessionIds)).run(); + } + + const bindingRows = this._db + .select({ chat_id: groupWorkspaces.chat_id }) + .from(groupWorkspaces) + .where(eq(groupWorkspaces.workspace_id, workspaceId)) + .all(); + this._db + .delete(groupWorkspaces) + .where(eq(groupWorkspaces.workspace_id, workspaceId)) + .run(); + this._db.delete(workspaces).where(eq(workspaces.id, workspaceId)).run(); + + let removedDirectory = false; + try { + if (existsSync(workspace.path)) { + rmSync(workspace.path, { recursive: true, force: true }); + removedDirectory = true; + } + } catch (err) { + this._logger.error( + { err, workspace_id: workspaceId, path: workspace.path }, + "failed to remove workspace directory; db rows already gone", + ); + } + + const result: WorkspaceDeleteResult = { + workspace_id: workspaceId, + workspace_name: workspace.name, + workspace_path: workspace.path, + removed_bindings: bindingRows.length, + removed_sessions: sessionIds.length, + removed_tasks: removedTasks, + removed_directory: removedDirectory, + }; + this._logger.info(result, "workspace deleted"); + return result; + } + private _defaultResolution( binding: GroupWorkspace | null, ): WorkspaceResolution { @@ -304,6 +399,7 @@ export class GroupWorkspaceStore { active_branch: null, created_at: now, updated_at: now, + last_active_at: now, }; this._db.insert(workspaces).values(workspace).run(); this._writeMetaFile(workspace); @@ -430,6 +526,39 @@ export class GroupWorkspaceStore { } } +/** + * Breakdown of what {@link GroupWorkspaceStore.deleteWorkspace} actually + * removed. The card uses these counts in the result summary so users see + * exactly how much state went away. + */ +export interface WorkspaceDeleteResult { + workspace_id: string; + workspace_name: string; + workspace_path: string; + removed_bindings: number; + removed_sessions: number; + removed_tasks: number; + removed_directory: boolean; +} + +/** Thrown when the caller asks to delete a workspace id that doesn't exist. */ +export class WorkspaceNotFoundError extends Error { + constructor(readonly workspace_id: string) { + super(`workspace id "${workspace_id}" does not exist`); + this.name = "WorkspaceNotFoundError"; + } +} + +/** Thrown when the caller asks to delete the reserved default workspace. */ +export class WorkspaceProtectedError extends Error { + constructor(readonly workspace_id: string) { + super( + `workspace "${workspace_id}" is protected (default fallback) and cannot be deleted`, + ); + this.name = "WorkspaceProtectedError"; + } +} + function _safeBasename(p: string): string { const trimmed = p.replace(/[\\/]+$/, ""); const idx = Math.max( diff --git a/src/shared/workspaces/types/workspace.ts b/src/shared/workspaces/types/workspace.ts index 019e389..60d1cb8 100644 --- a/src/shared/workspaces/types/workspace.ts +++ b/src/shared/workspaces/types/workspace.ts @@ -26,5 +26,7 @@ export const Workspace = z.object({ created_at: z.number(), /** Epoch ms when the workspace was last updated. */ updated_at: z.number(), + /** Epoch ms of the last dispatch/bind/active-state mutation. */ + last_active_at: z.number(), }); export interface Workspace extends z.infer {} diff --git a/tests/kernel/setting/fixtures.ts b/tests/kernel/setting/fixtures.ts new file mode 100644 index 0000000..d88e79a --- /dev/null +++ b/tests/kernel/setting/fixtures.ts @@ -0,0 +1,32 @@ +import type { Card, Element } from "@/community/feishu/messaging/types"; + +/** + * Walk a card element tree (depth-first) into a flat array so tests can + * assert on buttons/inputs regardless of how deeply they're nested inside + * forms/columns/collapsible panels. + */ +export function flattenElements(elements: Element[]): Element[] { + const out: Element[] = []; + const walk = (es: Element[]) => { + for (const e of es) { + out.push(e); + const maybeContainer = e as { elements?: Element[]; columns?: Element[] }; + if (Array.isArray(maybeContainer.elements)) walk(maybeContainer.elements); + if (Array.isArray(maybeContainer.columns)) walk(maybeContainer.columns); + } + }; + walk(elements); + return out; +} + +export function findElement( + elements: Element[], + // eslint-disable-next-line no-unused-vars + predicate: (e: Element) => boolean, +): Element | undefined { + return elements.find(predicate); +} + +export function stringifyCard(card: Card): string { + return JSON.stringify(card); +} diff --git a/tests/kernel/setting/setting-card.test.ts b/tests/kernel/setting/setting-card.test.ts new file mode 100644 index 0000000..422e6cf --- /dev/null +++ b/tests/kernel/setting/setting-card.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildSettingMainCard, + buildWorkspaceDeleteConfirmCard, + buildWorkspaceDetailCard, + SETTING_ACTION, + SETTING_FIELD, +} from "@/kernel/setting/setting-card"; +import type { GroupWorkspace, Workspace } from "@/shared"; + +import { + findElement, + flattenElements, + stringifyCard, +} from "./fixtures"; + +function makeWorkspace(overrides: Partial = {}): Workspace { + return { + id: "ws_abc", + name: "alpha", + path: "/tmp/ws_abc", + active_repo: "agentara", + active_branch: "dev", + created_at: 1_700_000_000_000, + updated_at: 1_700_000_000_000, + last_active_at: Date.now(), + ...overrides, + }; +} + +describe("buildSettingMainCard", () => { + test("renders the global-config form with all four fields", () => { + const card = buildSettingMainCard({ + agent: { + active_type: "claude-code", + available_types: ["claude-code", "codex"], + }, + config_values: { + agent_model: "claude-sonnet-4-6", + codex_isolate_host_env: false, + max_retries: 3, + }, + workspaces: [], + }); + const flat = flattenElements(card.body.elements); + const names = flat + .filter((e) => "name" in e && typeof e.name === "string") + .map((e) => (e as { name: string }).name); + expect(names).toContain(SETTING_FIELD.agentType); + expect(names).toContain(SETTING_FIELD.agentModel); + expect(names).toContain(SETTING_FIELD.codexIsolateHostEnv); + expect(names).toContain(SETTING_FIELD.maxRetries); + expect(names).toContain(SETTING_ACTION.saveConfig); + }); + + test("empty workspace list shows the empty-state hint", () => { + const card = buildSettingMainCard({ + agent: { active_type: "codex", available_types: ["codex"] }, + config_values: { + agent_model: "", + codex_isolate_host_env: true, + max_retries: 5, + }, + workspaces: [], + }); + expect(stringifyCard(card)).toContain("还没有任何 workspace"); + }); + + test("workspace row carries a detail callback with workspace_id", () => { + const card = buildSettingMainCard({ + agent: { active_type: "codex", available_types: ["codex"] }, + config_values: { + agent_model: "", + codex_isolate_host_env: false, + max_retries: 1, + }, + workspaces: [ + { + workspace: makeWorkspace(), + binding_count: 2, + is_current: true, + }, + ], + }); + const flat = flattenElements(card.body.elements); + const detailBtn = findElement(flat, (e) => + "behaviors" in e && + Array.isArray((e as { behaviors?: unknown[] }).behaviors) && + JSON.stringify(e).includes(SETTING_ACTION.wsDetail), + ); + expect(detailBtn).toBeDefined(); + expect(JSON.stringify(detailBtn)).toContain("ws_abc"); + expect(stringifyCard(card)).toContain("当前群"); + expect(stringifyCard(card)).toContain("2 群绑定"); + }); +}); + +describe("buildWorkspaceDetailCard", () => { + test("shows bindings, repos, and both back + delete buttons", () => { + const bindings: GroupWorkspace[] = [ + { + chat_id: "oc_xx", + workspace_id: "ws_abc", + workspace_name: "alpha", + workspace_path: "/tmp/ws_abc", + active_repo: "agentara", + active_branch: "dev", + created_at: 0, + updated_at: 0, + }, + ]; + const card = buildWorkspaceDetailCard({ + workspace: makeWorkspace(), + bindings, + repos: [ + { name: "agentara", branch: "dev", is_active: true }, + { name: "new-api", branch: "main", is_active: false }, + ], + is_protected: false, + }); + const json = stringifyCard(card); + expect(json).toContain("oc_xx"); + expect(json).toContain("agentara"); + expect(json).toContain("new-api"); + expect(json).toContain("活跃"); + expect(json).toContain(SETTING_ACTION.mainBack); + expect(json).toContain(SETTING_ACTION.wsDeletePrompt); + }); + + test("protected workspace only shows back button, not delete", () => { + const card = buildWorkspaceDetailCard({ + workspace: makeWorkspace({ name: "_default", path: "/tmp/_default" }), + bindings: [], + repos: [], + is_protected: true, + }); + const json = stringifyCard(card); + expect(json).toContain(SETTING_ACTION.mainBack); + expect(json).not.toContain(SETTING_ACTION.wsDeletePrompt); + }); +}); + +describe("buildWorkspaceDeleteConfirmCard", () => { + test("highlights the estimated blast radius and offers cancel/confirm", () => { + const card = buildWorkspaceDeleteConfirmCard({ + workspace: makeWorkspace(), + binding_count: 3, + estimated_session_count: 12, + }); + const json = stringifyCard(card); + expect(json).toContain("解绑"); + expect(json).toContain("3"); + expect(json).toContain("12"); + expect(json).toContain(SETTING_ACTION.wsDetail); // cancel returns to detail + expect(json).toContain(SETTING_ACTION.wsDeleteApply); + }); +}); diff --git a/tests/kernel/setup/cards.test.ts b/tests/kernel/setup/cards.test.ts index 5e8f229..9740748 100644 --- a/tests/kernel/setup/cards.test.ts +++ b/tests/kernel/setup/cards.test.ts @@ -97,6 +97,7 @@ describe("buildSwitchCard", () => { active_branch: "dev", created_at: Date.now(), updated_at: Date.now(), + last_active_at: Date.now(), }, ], current: { diff --git a/tests/kernel/workspaces/delete.test.ts b/tests/kernel/workspaces/delete.test.ts new file mode 100644 index 0000000..e74a1ee --- /dev/null +++ b/tests/kernel/workspaces/delete.test.ts @@ -0,0 +1,206 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database as SQLiteDatabase } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; + +import { + groupWorkspaces, + sessions, + workspaces, +} from "@/kernel/sessioning/data"; +import { tasks } from "@/kernel/tasking/data"; +import { + GroupWorkspaceStore, + WorkspaceNotFoundError, + WorkspaceProtectedError, +} from "@/kernel/workspaces"; +import { config } from "@/shared"; + +let homeDir: string; +let store: GroupWorkspaceStore; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- drizzle schema typing is not worth narrowing for tests. +let db: any; + +function freshDb(): ReturnType { + const sqlite = new SQLiteDatabase(":memory:"); + sqlite.run("PRAGMA foreign_keys = ON"); + const instance = drizzle(sqlite, { + schema: { workspaces, groupWorkspaces, sessions, tasks }, + }); + migrate(instance, { migrationsFolder: join(import.meta.dir, "..", "..", "..", "drizzle") }); + return instance; +} + +beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "agentara-delete-ws-test-")); + // GroupWorkspaceStore.ensureBaseDirs writes under config.paths.workspaces — + // point it at the temp home so the test doesn't touch the real ~/.agentara. + process.env.AGENTARA_HOME = homeDir; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- config.paths is lazily derived from env at boot; tests reach in. + (config as any).paths = { + ...config.paths, + home: homeDir, + workspaces: join(homeDir, "workspaces"), + default_workspace: join(homeDir, "workspaces", "_default"), + resolveWorkspacePathById: (id: string) => + join(homeDir, "workspaces", id), + resolveDataFilePath: (name: string) => join(homeDir, "data", name), + }; + mkdirSync(join(homeDir, "workspaces", "_default"), { recursive: true }); + db = freshDb(); + store = new GroupWorkspaceStore(db); +}); + +afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); +}); + +describe("GroupWorkspaceStore.deleteWorkspace", () => { + test("cascades bindings, sessions, tasks, and on-disk directory", () => { + const binding = store.upsertBinding("oc_aaa", { + workspace_name: "alpha", + }); + // A second chat bound to the same workspace so we also verify shared + // bindings are both removed. + store.upsertBinding("oc_bbb", { workspace_id: binding.workspace_id }); + + // Drop a fake file in the workspace dir so we can verify rmSync ran. + writeFileSync( + join(binding.workspace_path, "README.md"), + "hello\n", + ); + + const now = Date.now(); + db.insert(sessions) + .values([ + { + id: "s1", + agent_type: "claude-code", + cwd: binding.workspace_path, + channel_id: null, + chat_id: "oc_aaa", + thread_id: null, + first_message: "", + runner_session_id: null, + last_message_created_at: null, + created_at: now, + updated_at: now, + }, + { + id: "s2", + agent_type: "claude-code", + cwd: join(binding.workspace_path, "agentara"), + channel_id: null, + chat_id: "oc_bbb", + thread_id: null, + first_message: "", + runner_session_id: null, + last_message_created_at: null, + created_at: now, + updated_at: now, + }, + // Session in a different workspace path — must survive the delete. + { + id: "s3", + agent_type: "claude-code", + cwd: join(homeDir, "workspaces", "unrelated"), + channel_id: null, + chat_id: "oc_ccc", + thread_id: null, + first_message: "", + runner_session_id: null, + last_message_created_at: null, + created_at: now, + updated_at: now, + }, + ]) + .run(); + db.insert(tasks) + .values([ + { + id: "t1", + session_id: "s1", + type: "inbound_message", + status: "completed", + payload: {}, + created_at: now, + updated_at: now, + }, + { + id: "t2", + session_id: "s3", + type: "inbound_message", + status: "completed", + payload: {}, + created_at: now, + updated_at: now, + }, + ]) + .run(); + + const result = store.deleteWorkspace(binding.workspace_id); + + expect(result.removed_bindings).toBe(2); + expect(result.removed_sessions).toBe(2); + expect(result.removed_tasks).toBe(1); + expect(result.removed_directory).toBe(true); + + expect(store.getWorkspace(binding.workspace_id)).toBeNull(); + expect(store.getBinding("oc_aaa")).toBeNull(); + expect(store.getBinding("oc_bbb")).toBeNull(); + + const survivingSessions = db.select().from(sessions).all(); + expect(survivingSessions.map((s: { id: string }) => s.id)).toEqual(["s3"]); + const survivingTasks = db.select().from(tasks).all(); + expect(survivingTasks.map((t: { id: string }) => t.id)).toEqual(["t2"]); + expect(existsSync(binding.workspace_path)).toBe(false); + }); + + test("throws WorkspaceNotFoundError for unknown ids", () => { + expect(() => store.deleteWorkspace("ws_missing")).toThrow( + WorkspaceNotFoundError, + ); + }); + + test("refuses to delete the default workspace directory", () => { + const now = Date.now(); + db.insert(workspaces) + .values({ + id: "ws_default_row", + name: "_default", + path: config.paths.default_workspace, + active_repo: null, + active_branch: null, + created_at: now, + updated_at: now, + last_active_at: now, + }) + .run(); + expect(() => store.deleteWorkspace("ws_default_row")).toThrow( + WorkspaceProtectedError, + ); + expect(existsSync(config.paths.default_workspace)).toBe(true); + }); +}); + +describe("GroupWorkspaceStore.touchLastActive", () => { + test("updates last_active_at without mutating updated_at", () => { + const binding = store.upsertBinding("oc_touch", { + workspace_name: "beta", + }); + const before = store.getWorkspace(binding.workspace_id)!; + + // Advance the clock via the optional ts arg so the test doesn't need a + // real sleep to observe the bump. + const later = before.last_active_at + 10_000; + store.touchLastActive(binding.workspace_id, later); + + const after = store.getWorkspace(binding.workspace_id)!; + expect(after.last_active_at).toBe(later); + expect(after.updated_at).toBe(before.updated_at); + }); +}); From 2882139e225dd137a3f1eff615c34e5ec49b72a8 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 17:09:37 +0800 Subject: [PATCH 43/69] fix(workspaces): symlink global CLAUDE.md/REPOS.md/memory/skills into each workspace Codex resolves CLAUDE.md and @memory imports from cwd (the workspace root). Without these links Codex ran off a stale global AGENTS.md with `` and wrote its own auto-memory into `/memory/USER.md`, producing per-workspace memory islands that never reached the global SOUL/USER context. Only `.claude/skills/` is shared from `.claude/`; runtime state (local settings, todos, ide) stays per-workspace. `_ensureSymlink` is idempotent and backs up any real file/dir found at a link target as `.bak.` instead of overwriting. --- src/kernel/workspaces/store.ts | 91 +++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/src/kernel/workspaces/store.ts b/src/kernel/workspaces/store.ts index d06cfcd..add23ab 100644 --- a/src/kernel/workspaces/store.ts +++ b/src/kernel/workspaces/store.ts @@ -1,5 +1,14 @@ -import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, sep } from "node:path"; import dayjs from "dayjs"; import { eq, inArray, like, or } from "drizzle-orm"; @@ -411,6 +420,83 @@ export class GroupWorkspaceStore { mkdirSync(workspacePath, { recursive: true }); this._logger.info(`Created workspace: ${workspacePath}`); } + this._linkGlobalAssets(workspacePath); + } + + /** + * Symlink the shared instruction + memory surface from agentara home into + * every workspace root. Without this, Codex (whose `@import` resolver uses + * cwd as baseDir, and whose own auto-memory writes under `/memory/`) + * ends up with a stale `` AGENTS.md at the home + * level and a per-workspace memory island that never reaches the global + * SOUL/USER context. Symlinking is chosen over copying so writes on either + * side are seen by the other. + * + * Only `.claude/skills/` is shared from `.claude/` — the rest (runtime + * state, local settings, todos) stays per-workspace to avoid cross-session + * contention. + */ + private _linkGlobalAssets(workspacePath: string): void { + // Never clobber agentara home itself; an in-home "workspace" would + // recurse onto its own files. + if (workspacePath === config.paths.home) return; + // Only manage workspaces under the managed workspaces root. If a user + // points at an arbitrary path, leave it alone. + if (!workspacePath.startsWith(config.paths.workspaces + sep)) return; + + const linkSpecs: Array<{ src: string; dst: string }> = [ + { src: join(config.paths.home, "CLAUDE.md"), dst: join(workspacePath, "CLAUDE.md") }, + { src: config.paths.repos_md, dst: join(workspacePath, "REPOS.md") }, + { src: config.paths.memory, dst: join(workspacePath, "memory") }, + { + src: config.paths.skills, + dst: join(workspacePath, ".claude", "skills"), + }, + ]; + + for (const { src, dst } of linkSpecs) { + if (!existsSync(src)) continue; + const parent = join(dst, ".."); + if (!existsSync(parent)) { + try { + mkdirSync(parent, { recursive: true }); + } catch (err) { + this._logger.warn({ err, parent }, "failed to create parent dir for symlink"); + continue; + } + } + this._ensureSymlink(src, dst); + } + } + + /** + * Idempotently ensure `dst` is a symlink pointing at `src`. If `dst` is + * already a symlink to the same target, no-op. If it's a symlink to a + * different target, replace it. If it's a real file or directory, move it + * aside to `.bak.` before creating the link, so we never + * silently destroy existing content. + */ + private _ensureSymlink(src: string, dst: string): void { + try { + const st = lstatSync(dst, { throwIfNoEntry: false }); + if (st) { + if (st.isSymbolicLink()) { + if (readlinkSync(dst) === src) return; + rmSync(dst); + } else { + const backup = `${dst}.bak.${Date.now()}`; + renameSync(dst, backup); + this._logger.info( + { dst, backup }, + "backed up existing workspace asset before linking", + ); + } + } + symlinkSync(src, dst); + this._logger.info({ src, dst }, "linked global asset into workspace"); + } catch (err) { + this._logger.warn({ err, src, dst }, "failed to link global asset"); + } } private _newWorkspaceId(): string { @@ -446,6 +532,7 @@ export class GroupWorkspaceStore { for (const row of rows) { const desired = config.paths.resolveWorkspacePathById(row.id); if (row.path === desired && existsSync(desired)) { + this._linkGlobalAssets(desired); this._writeMetaFile(row); continue; } From d61e2c70d916a541048b28ec5c5dd80f5caf5b6e Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 17:36:37 +0800 Subject: [PATCH 44/69] fix(kernel): polish /setting panel and fix Feishu card validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unique button name per workspace so Feishu accepts the card (11310 "name duplicate" otherwise blocks the whole render) - drop unsupported ; limit to grey/green/red - inline a 删除 button next to 详情 on each row, so listing-level deletion doesn't require drilling into the detail view - re-layout each row into title / active repo / meta lines with id and a colour-coded last-active badge (green <3d, red 超过 N 个月 未活跃 once 30d+) so dormant workspaces surface at a glance - append 返回设置面板 on every result card so the user isn't stranded after save/delete completes - read the active repo branch from on-disk HEAD (matching /status) instead of the stored active_branch hint, which drifts after any user-initiated checkout - surface the real Feishu error code/msg on render failure instead of axios's generic "Request failed with status code 400" --- src/kernel/setting/setting-card.ts | 158 ++++++++++++++++++---- src/kernel/setting/setting-flow.ts | 47 ++++++- tests/kernel/setting/setting-card.test.ts | 42 +++++- 3 files changed, 216 insertions(+), 31 deletions(-) diff --git a/src/kernel/setting/setting-card.ts b/src/kernel/setting/setting-card.ts index 563fc66..26ace70 100644 --- a/src/kernel/setting/setting-card.ts +++ b/src/kernel/setting/setting-card.ts @@ -7,6 +7,7 @@ import type { ButtonElement, Card, CheckerElement, + ColumnSetElement, Element, FormElement, InputElement, @@ -63,6 +64,15 @@ export interface SettingMainCardOptions { workspace: Workspace; binding_count: number; is_current: boolean; + /** `_default` and similar reserved paths skip the delete button. */ + is_protected: boolean; + /** + * Live branch name the workspace's active repo is currently on (from the + * on-disk HEAD). `null` when there's no active_repo or HEAD is detached. + * Resolved by the flow so the card never displays the stale stored + * `active_branch` hint. + */ + active_branch_head: string | null; }>; current_chat_id?: string | null; } @@ -117,14 +127,16 @@ export interface WorkspaceDetailCardOptions { bindings: GroupWorkspace[]; repos: Array<{ name: string; branch: string | null; is_active: boolean }>; is_protected: boolean; + /** Live HEAD of the active repo; matches the per-repo row below. */ + active_branch_head: string | null; } export function buildWorkspaceDetailCard( options: WorkspaceDetailCardOptions, ): Card { - const { workspace, bindings, repos, is_protected } = options; + const { workspace, bindings, repos, is_protected, active_branch_head } = options; const activeRepoLine = workspace.active_repo - ? `\`${formatRepoRef(workspace.active_repo, workspace.active_branch ?? "")}\`` + ? `\`${formatRepoRef(workspace.active_repo, active_branch_head ?? "(游离)")}\`` : "(未设置)"; const elements: Element[] = [ @@ -225,19 +237,38 @@ export function buildWorkspaceDeleteConfirmCard( } /** - * Terminal result card reused by every setting flow (save / delete). Thin - * wrapper over the shared `buildResultCard` so we can tweak the title in - * one place if needed later. + * Terminal result card reused by every setting flow (save / delete / error). + * Always appends a "返回设置面板" button so the user can re-enter the panel + * without having to re-send the slash command. Disable via `show_back: false` + * for transient fail states that shouldn't offer re-entry. */ export function buildSettingResultCard( summary: string, detail: string[] = [], + options: { show_back?: boolean } = {}, ): Card { - return buildResultCard({ + const card = buildResultCard({ title: "设置面板", summary, detail, }); + if (options.show_back ?? true) { + const backBtn: ButtonElement = { + tag: "button", + name: "setting_result_back_btn", + text: { tag: "plain_text", content: "返回设置面板" }, + type: "default", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: SETTING_ACTION.mainBack }, + }, + ], + }; + card.body.elements.push(backBtn); + } + return card; } function _buildConfigForm(options: SettingMainCardOptions): FormElement { @@ -325,24 +356,48 @@ function _buildWorkspaceRow(entry: { workspace: Workspace; binding_count: number; is_current: boolean; + is_protected: boolean; + active_branch_head: string | null; }): Element { - const { workspace, binding_count, is_current } = entry; - const activeLine = workspace.active_repo - ? `\`${formatRepoRef(workspace.active_repo, workspace.active_branch ?? "")}\`` - : "(未设置主仓库)"; - const marks: string[] = []; - if (is_current) marks.push("当前群"); - if (binding_count > 0) marks.push(`${binding_count} 群绑定`); - const summaryLine = - `- **${workspace.name}** \`${workspace.id}\` ` + - `${activeLine} · 活跃 ${_formatRelative(workspace.last_active_at)}` + - (marks.length ? ` · ${marks.join(" / ")}` : ""); + const { workspace, binding_count, is_current, is_protected, active_branch_head } = entry; + + // Primary line: workspace name in bold. The stable id moves to the meta + // line so the title stays short and scannable. + const titleEl = buildMarkdown(`**${workspace.name}**`); + + // Secondary line: currently active repo/branch — the single most + // operationally relevant fact for each row. Branch comes from the on-disk + // HEAD (passed in as `active_branch_head`), matching `/status`: the stored + // `active_branch` is only a hint and drifts after the user `checkout`s. + const activeEl = buildMarkdown( + workspace.active_repo + ? `\`${formatRepoRef(workspace.active_repo, active_branch_head ?? "(游离)")}\`` + : "未设置主仓库", + { text_size: "notation" }, + ); + + // Meta line: id + last-active badge + (optional) binding count + current + // chat tag. The active badge changes color by age so dormant workspaces + // are visually obvious without the user having to eyeball timestamps. + const activeBadge = _formatActiveBadge(workspace.last_active_at); + const metaParts: string[] = [ + `ID \`${workspace.id}\``, + `${activeBadge.text}`, + ]; + if (binding_count > 0) { + metaParts.push(`${binding_count} 群绑定`); + } + if (is_current) { + metaParts.push("**当前群**"); + } + const metaEl = buildMarkdown(metaParts.join(" · "), { text_size: "notation" }); const detailBtn: ButtonElement = { tag: "button", - name: "setting_ws_detail_btn", + name: `setting_ws_detail_btn_${workspace.id}`, text: { tag: "plain_text", content: "详情" }, type: "default", + width: "fill", behaviors: [ { type: "callback", @@ -353,6 +408,37 @@ function _buildWorkspaceRow(entry: { }, ], }; + const deleteBtn: ButtonElement = { + tag: "button", + name: `setting_ws_delete_btn_${workspace.id}`, + text: { tag: "plain_text", content: "删除" }, + type: "danger", + width: "fill", + behaviors: [ + { + type: "callback", + value: { + action: SETTING_ACTION.wsDeletePrompt, + workspace_id: workspace.id, + }, + }, + ], + }; + + // Buttons sit in a narrow right-hand column; nesting a tight column_set + // inside it keeps the two buttons visually adjacent (no big gap) while + // still letting the left column stretch to take all remaining width. + const buttonStack: ColumnSetElement = { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "4px", + columns: is_protected + ? [{ tag: "column", width: "weighted", weight: 1, elements: [detailBtn] }] + : [ + { tag: "column", width: "weighted", weight: 1, elements: [detailBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [deleteBtn] }, + ], + }; return { tag: "column_set", @@ -363,12 +449,15 @@ function _buildWorkspaceRow(entry: { tag: "column", width: "weighted", weight: 3, - elements: [buildMarkdown(summaryLine)], + vertical_spacing: "4px", + vertical_align: "center", + elements: [titleEl, activeEl, metaEl], }, { tag: "column", - width: "80px", - elements: [detailBtn], + width: is_protected ? "90px" : "170px", + vertical_align: "center", + elements: [buttonStack], }, ], }; @@ -443,10 +532,7 @@ function _buildDeleteConfirmRow(workspaceId: string): Element { behaviors: [ { type: "callback", - value: { - action: SETTING_ACTION.wsDetail, - workspace_id: workspaceId, - }, + value: { action: SETTING_ACTION.mainBack }, }, ], }; @@ -491,3 +577,25 @@ function _formatRelative(ms: number): string { if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`; return dayjs(ms).format("YYYY-MM-DD"); } + +const DAY_MS = 86_400_000; + +/** + * Color-coded freshness label for a workspace's `last_active_at`. + * + * - ≤ 3 days → green "活跃 …" — recently used, safe. + * - ≤ 30 days → grey "活跃 …" — cold but not abandoned. + * - > 30 days → red "超过 N 个月未活跃" — explicit warning to consider pruning. + */ +function _formatActiveBadge(ms: number): { text: string; color: string } { + if (!ms) return { text: "活跃未知", color: "grey" }; + const diff = Date.now() - ms; + if (diff < 3 * DAY_MS) { + return { text: `活跃 ${_formatRelative(ms)}`, color: "green" }; + } + if (diff < 30 * DAY_MS) { + return { text: `活跃 ${_formatRelative(ms)}`, color: "grey" }; + } + const months = Math.max(1, Math.floor(diff / (30 * DAY_MS))); + return { text: `超过 ${months} 个月未活跃`, color: "red" }; +} diff --git a/src/kernel/setting/setting-flow.ts b/src/kernel/setting/setting-flow.ts index 53d2c51..1d48a7f 100644 --- a/src/kernel/setting/setting-flow.ts +++ b/src/kernel/setting/setting-flow.ts @@ -85,11 +85,15 @@ export class SettingFlow { replyInThread: false, }); } catch (err) { - this._logger.error({ err, chat_id: chatId }, "failed to render setting card"); - await this._replyText( - message, - `❌ 渲染设置面板失败:${(err as Error).message}`, + const detail = _summarizeFeishuError(err); + this._logger.error( + { err: detail, chat_id: chatId }, + "failed to render setting card", ); + const reason = detail.msg + ? `${detail.msg}${detail.code ? ` (code ${detail.code})` : ""}` + : (err as Error).message; + await this._replyText(message, `❌ 渲染设置面板失败:${reason}`); } } @@ -262,6 +266,9 @@ export class SettingFlow { .listBindings() .filter((b) => b.workspace_id === workspaceId); const repos = _scanRepos(workspace); + const activeBranchHead = workspace.active_repo + ? readRepoHead(join(workspace.path, workspace.active_repo)) ?? null + : null; await this._tryUpdateCard( channel, payload.message_id, @@ -270,6 +277,7 @@ export class SettingFlow { bindings, repos, is_protected: workspace.path === config.paths.default_workspace, + active_branch_head: activeBranchHead, }), "ws-detail", ); @@ -392,6 +400,10 @@ export class SettingFlow { workspace: ws, binding_count: bindingCounts.get(ws.id) ?? 0, is_current: currentBinding?.workspace_id === ws.id, + is_protected: ws.path === config.paths.default_workspace, + active_branch_head: ws.active_repo + ? readRepoHead(join(ws.path, ws.active_repo)) ?? null + : null, })), current_chat_id: currentChatId, }); @@ -421,7 +433,7 @@ export class SettingFlow { await channel.updateRawCard(messageId, card); } catch (err) { this._logger.error( - { err, stage, message_id: messageId }, + { err: _summarizeFeishuError(err), stage, message_id: messageId }, "setting updateRawCard failed", ); } @@ -500,3 +512,28 @@ function _scanRepos(workspace: Workspace): RepoSummary[] { // deep paths — not used today but keeps the barrel clean if we ever surface // binding details publicly. export type { GroupWorkspace }; + +/** + * Extract the code/msg/status trio from a Feishu/axios error so logs and + * user-facing messages carry the actual server reason instead of the generic + * "Request failed with status code 400". + */ +function _summarizeFeishuError(err: unknown): { + code?: number; + msg?: string; + status?: number; +} { + if (!err || typeof err !== "object") return {}; + const candidate = err as { + response?: { + status?: number; + data?: { code?: number; msg?: string }; + }; + message?: string; + }; + return { + code: candidate.response?.data?.code, + msg: candidate.response?.data?.msg ?? candidate.message, + status: candidate.response?.status, + }; +} diff --git a/tests/kernel/setting/setting-card.test.ts b/tests/kernel/setting/setting-card.test.ts index 422e6cf..f7cd3f2 100644 --- a/tests/kernel/setting/setting-card.test.ts +++ b/tests/kernel/setting/setting-card.test.ts @@ -80,6 +80,8 @@ describe("buildSettingMainCard", () => { workspace: makeWorkspace(), binding_count: 2, is_current: true, + is_protected: false, + active_branch_head: "dev", }, ], }); @@ -94,6 +96,40 @@ describe("buildSettingMainCard", () => { expect(stringifyCard(card)).toContain("当前群"); expect(stringifyCard(card)).toContain("2 群绑定"); }); + + test("each non-protected row also carries an inline delete button", () => { + const card = buildSettingMainCard({ + agent: { active_type: "codex", available_types: ["codex"] }, + config_values: { + agent_model: "", + codex_isolate_host_env: false, + max_retries: 1, + }, + workspaces: [ + { + workspace: makeWorkspace({ id: "ws_del1", name: "one" }), + binding_count: 0, + is_current: false, + is_protected: false, + active_branch_head: "main", + }, + { + workspace: makeWorkspace({ id: "ws_prot", name: "_default" }), + binding_count: 0, + is_current: false, + is_protected: true, + active_branch_head: null, + }, + ], + }); + const json = stringifyCard(card); + // Non-protected row gets a delete button whose callback targets its id. + expect(json).toContain(`setting_ws_delete_btn_ws_del1`); + expect(json).toContain(SETTING_ACTION.wsDeletePrompt); + // Protected row keeps its detail button but omits the delete button. + expect(json).toContain(`setting_ws_detail_btn_ws_prot`); + expect(json).not.toContain(`setting_ws_delete_btn_ws_prot`); + }); }); describe("buildWorkspaceDetailCard", () => { @@ -118,6 +154,7 @@ describe("buildWorkspaceDetailCard", () => { { name: "new-api", branch: "main", is_active: false }, ], is_protected: false, + active_branch_head: "dev", }); const json = stringifyCard(card); expect(json).toContain("oc_xx"); @@ -134,6 +171,7 @@ describe("buildWorkspaceDetailCard", () => { bindings: [], repos: [], is_protected: true, + active_branch_head: null, }); const json = stringifyCard(card); expect(json).toContain(SETTING_ACTION.mainBack); @@ -152,7 +190,9 @@ describe("buildWorkspaceDeleteConfirmCard", () => { expect(json).toContain("解绑"); expect(json).toContain("3"); expect(json).toContain("12"); - expect(json).toContain(SETTING_ACTION.wsDetail); // cancel returns to detail + // Cancel returns to the main panel regardless of whether the user came + // from the list or the detail view. + expect(json).toContain(SETTING_ACTION.mainBack); expect(json).toContain(SETTING_ACTION.wsDeleteApply); }); }); From 485d1124af0e8adc320f0d5623dcbcf66e1d7e4a Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 19:10:28 +0800 Subject: [PATCH 45/69] refactor(commands): regroup and declutter /help card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous /help dumped all 15+ commands into one bullet list, with a redundant preface and every command wrapped in inline code (which Feishu renders as a separate bordered pill). Hard to scan and visually noisy. - bucket commands into intent groups (常用 / Workspace / 仓库操作 / Agent / 群权限 / 话题响应), each with a bold heading + plain bullet list — no intro, no collapsible panel, no per-command code border - drive the list from a declared HELP_GROUPS table so help copy no longer duplicates handler descriptions and is easy to reorder - separate fallback_text builder so non-card channels still get a readable plain-text dump --- src/kernel/commands/handlers.ts | 139 +++++++++++++++++++++++++++----- 1 file changed, 121 insertions(+), 18 deletions(-) diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index e606580..c9e3af8 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -18,6 +18,9 @@ import { } from "@/kernel/workspaces"; import { loadPredefinedRepos } from "@/shared"; +import type { Card, Element } from "../../community/feishu/messaging/types"; +import { buildMarkdown } from "../setup/card-ui"; + import { buildCommandCard } from "./cards"; import type { CardCommandResult, @@ -634,28 +637,128 @@ const unmuteHandler = buildMuteHandler( true, ); +/** + * Grouped `/help` card. + * + * A flat list of 15+ commands is noisy, so commands are bucketed by intent + * and rendered as plain bullet lists under a bold group heading. No intro + * text, no collapsible panels, no nested frames — just readable markdown. + */ +interface HelpCommand { + usage: string; + note: string; +} +interface HelpGroup { + title: string; + commands: HelpCommand[]; +} + +const HELP_GROUPS: HelpGroup[] = [ + { + title: "🚀 常用", + commands: [ + { usage: "/setting", note: "打开设置面板(全局配置 + workspace 管理)" }, + { usage: "/new <消息>", note: "开启新会话 + 新话题(须在主群、非话题内)" }, + { usage: "/stop", note: "取消当前 session 正在执行的任务" }, + { usage: "/help", note: "显示本消息" }, + ], + }, + { + title: "📦 Workspace", + commands: [ + { usage: "/status", note: "查看当前群的绑定及已克隆的仓库" }, + { usage: "/ls", note: "列出当前群 workspace 下的所有仓库" }, + { usage: "/setup", note: "打开 workspace 配置卡片(仅群聊)" }, + { usage: "/switch", note: "打开 workspace 切换卡片(群聊 & 单聊)" }, + { usage: "/bind [workspace-id]", note: "绑定当前群到一个 workspace" }, + { usage: "/unbind", note: "清除当前群的绑定(回退到默认 workspace)" }, + { usage: "/workspaces", note: "/setting 的快捷入口" }, + ], + }, + { + title: "🔀 仓库操作", + commands: [ + { usage: "/sync", note: "对当前 workspace 下每个仓库 fetch + 快进拉取" }, + { usage: "/clone [别名]", note: "克隆仓库到当前群 workspace" }, + { usage: "/checkout <分支>", note: "切换当前活跃仓库的分支" }, + ], + }, + { + title: "🤖 Agent", + commands: [ + { + usage: "/agent [list|use |reset]", + note: "查看或切换运行时默认 Agent", + }, + { usage: "/agents", note: "查看可选 Agent 列表" }, + ], + }, + { + title: "👥 群 / 权限", + commands: [ + { + usage: "/group <群名> @user...", + note: "机器人建群并自动 /setup(仅单聊)", + }, + { usage: "/ungroup", note: "解散机器人创建的群" }, + { usage: "/allow @user...", note: "把 @ 的人加到机器人白名单" }, + ], + }, + { + title: "🔕 话题响应", + commands: [ + { usage: "/mute", note: "让当前话题不再自动响应 @ 提醒" }, + { usage: "/unmute", note: "恢复当前话题的自动响应" }, + ], + }, +]; + +function _buildHelpCard(): Card { + const elements: Element[] = []; + for (const group of HELP_GROUPS) { + const lines = [ + `**${group.title}**`, + ...group.commands.map((c) => `- ${c.usage} — ${c.note}`), + ].join("\n"); + elements.push(buildMarkdown(lines)); + } + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: "❓ 可用命令" }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +function _buildHelpFallbackText(): string { + const lines: string[] = ["可用命令"]; + for (const group of HELP_GROUPS) { + lines.push("", group.title); + for (const c of group.commands) { + lines.push(`- ${c.usage} — ${c.note}`); + } + } + return lines.join("\n"); +} + export const helpHandler: CommandHandler = { name: "help", description: "/help — 显示所有可用命令", async execute() { - return cardReply("可用命令", [], { - sections: [ - { - title: "命令", - lines: [ - ...BUILTIN_COMMANDS.map((h) => `- ${h.description}`), - "- /help — 显示本消息", - "- /stop — 取消当前 session 正在执行的任务", - "- /setting — 打开设置面板(全局配置 + workspace 管理)", - "- /workspaces — 打开设置面板(快捷入口,等价于 /setting)", - "- /setup — 打开 workspace 配置卡片(仅群聊)", - "- /switch — 打开 workspace 切换卡片(群聊 & 单聊)", - "- /group <群名> @user... — 机器人建群并自动 /setup(仅单聊)", - "- /new <消息> — 开启新会话 + 新话题(须在主群,非话题内)", - ], - }, - ], - }); + const result: CardCommandResult = { + kind: "card", + card: _buildHelpCard(), + fallback_text: _buildHelpFallbackText(), + }; + return result; }, }; From 9bc233c97ca8c576f3e2de14b7c543253b7e7570 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 19:51:16 +0800 Subject: [PATCH 46/69] fix(claude-gated): parallelize country probes, drop ipapi.co The previous detection walked the probe list serially with a 2s per-probe timeout. When ipapi.co was rate-limited or slow (common from shared egress IPs), we'd block the full 2s before even trying the next endpoint, making worst-case detection ~6s and frequently pushing the whole claude-gated dispatch past its tolerance. - race every probe in parallel via a small _firstNonNull helper; the first successful ISO code wins, null only if every probe fails or times out - drop ipapi.co, which is the endpoint that most often forces us to pay the full 2s timeout - worst-case detection is now ~2s regardless of how many endpoints are slow or offline --- src/plugins/_country-check.ts | 112 +++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/src/plugins/_country-check.ts b/src/plugins/_country-check.ts index 845755c..f5306d1 100644 --- a/src/plugins/_country-check.ts +++ b/src/plugins/_country-check.ts @@ -3,16 +3,18 @@ import { createLogger } from "@/shared"; const _logger = createLogger("country-check"); /** - * Endpoints that return the caller's country as a 2-letter ISO code. Tried - * in order with a short timeout; the first successful response wins. All - * three return plain text (or JSON we can regex), so no SDK dependency. + * Endpoints that return the caller's country as a 2-letter ISO code. All + * probes fire in parallel and the first successful answer wins — any single + * endpoint being slow, rate-limited, or offline no longer stalls the gate. + * + * `ipapi.co` was dropped: it rate-limits aggressively from shared egress + * IPs, which was the main source of the 2s timeout we'd then serialize on. * * Mirrors the zshrc preamble so agentara spawns fire under the same guard: * if the outbound proxy isn't landing somewhere expected, we bail loudly * before the agent starts billing tokens. */ const COUNTRY_PROBES = [ - { url: "https://ipapi.co/country/", kind: "text" as const }, { url: "https://ifconfig.co/country-iso", kind: "text" as const }, { url: "https://api.country.is/", kind: "country_is_json" as const }, ]; @@ -20,10 +22,10 @@ const COUNTRY_PROBES = [ const DEFAULT_TIMEOUT_MS = 2000; /** - * Probe several IP-geolocation endpoints and return the first ISO country - * code they agree on shape-wise. Returns `null` when every probe fails - * (network down, all providers rate-limited, etc.) — callers distinguish - * that from a successful detection with `country !== "US"`. + * Race every IP-geolocation probe in parallel and return the first ISO + * country code that comes back. Resolves to `null` only when every probe + * fails (network down, all providers rate-limited, etc.) — callers + * distinguish that from a successful detection with `country !== "US"`. */ export async function detectCountry(options?: { /** Proxy URL (e.g. `http://127.0.0.1:7897`). Skipped when undefined. */ @@ -34,37 +36,73 @@ export async function detectCountry(options?: { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; const proxy = options?.proxy; - for (const probe of COUNTRY_PROBES) { - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - let res: Response; - try { - // Bun's `fetch` accepts a `proxy` option natively — no undici - // ProxyAgent setup needed. The signal handles the timeout. - res = await fetch(probe.url, { - signal: controller.signal, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun's proxy option isn't in lib.dom.fetch yet - ...(proxy ? ({ proxy } as any) : {}), - }); - } finally { - clearTimeout(timer); - } - if (!res.ok) continue; - const text = await res.text(); - const parsed = _parseCountry(text, probe.kind); - if (parsed) { - _logger.debug( - { probe: probe.url, country: parsed }, - "country detected", - ); - return parsed; - } - } catch (err) { - _logger.debug({ err, probe: probe.url }, "country probe failed"); + const probes = COUNTRY_PROBES.map((probe) => + _runProbe(probe, { proxy, timeoutMs }), + ); + return _firstNonNull(probes); +} + +async function _runProbe( + probe: (typeof COUNTRY_PROBES)[number], + options: { proxy?: string; timeoutMs: number }, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs); + try { + // Bun's `fetch` accepts a `proxy` option natively — no undici + // ProxyAgent setup needed. The signal handles the timeout. + const res = await fetch(probe.url, { + signal: controller.signal, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun's proxy option isn't in lib.dom.fetch yet + ...(options.proxy ? ({ proxy: options.proxy } as any) : {}), + }); + if (!res.ok) return null; + const text = await res.text(); + const parsed = _parseCountry(text, probe.kind); + if (parsed) { + _logger.debug({ probe: probe.url, country: parsed }, "country detected"); } + return parsed; + } catch (err) { + _logger.debug({ err, probe: probe.url }, "country probe failed"); + return null; + } finally { + clearTimeout(timer); } - return null; +} + +/** + * Resolve with the first non-null value produced by any of the input + * promises. Resolves to `null` when every promise yields null/rejects — + * rejections are treated as failed probes, not fatal errors. + */ +function _firstNonNull( + promises: Array>, +): Promise { + return new Promise((resolve) => { + if (promises.length === 0) { + resolve(null); + return; + } + let pending = promises.length; + let settled = false; + const finish = (value: T | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + for (const p of promises) { + p.then( + (v) => { + if (v !== null) finish(v); + else if (--pending === 0) finish(null); + }, + () => { + if (--pending === 0) finish(null); + }, + ); + } + }); } function _parseCountry( From 8f792a1fc97cd30a52a6e4a9bf9648e03443ce43 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 20:03:22 +0800 Subject: [PATCH 47/69] fix(card-ui): strip mentions from result-card subtitles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result-card summaries often carry an mention so the body renders a proper @用户 link. But the subtitle built by buildCardIntro wraps its text in , and Feishu doesn't re-parse the tag inside , so the raw `at id=ou_...` attribute text leaks into the card header (visible on permission-decide results). - summarizeForSubtitle now drops tags before truncating - collapses the resulting double spaces so nothing reads awkwardly --- src/kernel/setup/card-ui.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/kernel/setup/card-ui.ts b/src/kernel/setup/card-ui.ts index 75d1d40..3a749f4 100644 --- a/src/kernel/setup/card-ui.ts +++ b/src/kernel/setup/card-ui.ts @@ -158,7 +158,17 @@ export function inferToneFromSummary(summary: string): CardTone { } function summarizeForSubtitle(summary: string): string { - return summary.replace(/^[^\p{L}\p{N}`]+/u, "").slice(0, 80); + // Feishu doesn't re-parse `` tags inside `` wrappers, which is + // what `buildCardIntro` uses for subtitles — leaving the tag in would + // dump its raw `at id=...` attribute text into the card. Strip mentions + // here so the subtitle stays plain. Also collapse the resulting double + // spaces so it reads cleanly. + return summary + .replace(/]*>\s*<\/at>/g, "") + .replace(/^[^\p{L}\p{N}`]+/u, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80); } /** From 35591acff3d55837ff04b2afef254691f1f0c542 Mon Sep 17 00:00:00 2001 From: xluos Date: Thu, 23 Apr 2026 20:03:49 +0800 Subject: [PATCH 48/69] feat(permission): add session-scoped "always allow" option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frequently-used tools (Bash, Edit) trigger a permission card on every call, which gets noisy once the user has already decided the agent can be trusted with that tool in the current session. - add a third button "🔓 批准并在本次会话内不再询问该工具" on the permission card, placed on its own row so the longer copy isn't squashed and users don't mistake it for a single-shot approve - PermissionFlow now keeps an in-memory session_id -> Set allowlist; request() short-circuits to `allow` when a match exists, without sending a card or touching Feishu - scope is strictly per-session, per-tool; the map is dropped on kernel restart so trust never silently carries across boots - expose clearSession(sessionId) for eager cleanup when a session tears down - new result-card outcome `allowed_session` documents the decision on the updated card so the chat history stays auditable - tests cover the auto-allow short-circuit and clearSession behavior --- src/kernel/permission/permission-card.ts | 56 +++++++--- src/kernel/permission/permission-flow.ts | 64 ++++++++++- .../kernel/permission/permission-flow.test.ts | 105 ++++++++++++++++++ 3 files changed, 206 insertions(+), 19 deletions(-) diff --git a/src/kernel/permission/permission-card.ts b/src/kernel/permission/permission-card.ts index 1c4ddf0..a4c8811 100644 --- a/src/kernel/permission/permission-card.ts +++ b/src/kernel/permission/permission-card.ts @@ -4,7 +4,11 @@ import type { ColumnSetElement, Element, } from "../../community/feishu/messaging/types"; -import { buildCardIntro, buildMarkdown, buildResultCard } from "../setup/card-ui"; +import { + buildCardIntro, + buildMarkdown, + buildResultCard, +} from "../setup/card-ui"; /** * `action` discriminator echoed back on `card.action.trigger` when the @@ -17,6 +21,11 @@ export const PERMISSION_ACTION = "permission_decide"; * the click result; `request_id` correlates with the pending map in * {@link PermissionFlow}. * + * `allow_session` is a session-scoped "always allow this tool" shortcut — + * the flow adds the tool name to an in-memory allow list keyed by + * `session_id`, so subsequent calls for the same tool don't prompt again. + * Scope is the current session only and the list clears on kernel restart. + * * The index signature is there only to satisfy the `CallbackValue = * Record` contract on {@link CallbackBehavior}; the * real shape is the three named fields above it. @@ -24,7 +33,7 @@ export const PERMISSION_ACTION = "permission_decide"; export interface PermissionCallbackValue { action: typeof PERMISSION_ACTION; request_id: string; - decision: "allow" | "deny"; + decision: "allow" | "deny" | "allow_session"; [key: string]: unknown; } @@ -63,7 +72,7 @@ export function buildPermissionCard(options: { ); } - elements.push(_buildButtonRow(options.request_id)); + elements.push(...buildButtonRows(options.request_id)); return { schema: "2.0", @@ -92,6 +101,7 @@ export function buildPermissionResultCard(options: { tool_name: string; outcome: | "allowed" + | "allowed_session" | "denied" | "timeout" | "wrong_operator" @@ -108,6 +118,11 @@ export function buildPermissionResultCard(options: { ? `✅ 已批准 \`${tool_name}\`。` : `✅ 已批准 \`${tool_name}\`。`; break; + case "allowed_session": + summary = decided_by + ? `🔓 已批准 \`${tool_name}\`,并在本次会话内对该工具放行。` + : `🔓 已批准 \`${tool_name}\`,并在本次会话内对该工具放行。`; + break; case "denied": summary = decided_by ? `🚫 已拒绝 \`${tool_name}\`。` @@ -143,24 +158,21 @@ function _formatInputPreview(input: unknown): string { } } -function _buildButtonRow(requestId: string): ColumnSetElement { - const allowValue: PermissionCallbackValue = { +function buildButtonRows(requestId: string): Element[] { + const mkValue = ( + decision: PermissionCallbackValue["decision"], + ): PermissionCallbackValue => ({ action: PERMISSION_ACTION, request_id: requestId, - decision: "allow", - }; - const denyValue: PermissionCallbackValue = { - action: PERMISSION_ACTION, - request_id: requestId, - decision: "deny", - }; + decision, + }); const approveBtn: ButtonElement = { tag: "button", name: "permission_allow", text: { tag: "plain_text", content: "✅ 批准" }, type: "primary", width: "fill", - behaviors: [{ type: "callback", value: allowValue }], + behaviors: [{ type: "callback", value: mkValue("allow") }], }; const denyBtn: ButtonElement = { tag: "button", @@ -168,9 +180,20 @@ function _buildButtonRow(requestId: string): ColumnSetElement { text: { tag: "plain_text", content: "🚫 拒绝" }, type: "danger", width: "fill", - behaviors: [{ type: "callback", value: denyValue }], + behaviors: [{ type: "callback", value: mkValue("deny") }], }; - return { + const allowSessionBtn: ButtonElement = { + tag: "button", + name: "permission_allow_session", + text: { + tag: "plain_text", + content: "🔓 批准并在本次会话内不再询问该工具", + }, + type: "default", + width: "fill", + behaviors: [{ type: "callback", value: mkValue("allow_session") }], + }; + const primaryRow: ColumnSetElement = { tag: "column_set", flex_mode: "stretch", horizontal_spacing: "12px", @@ -179,4 +202,7 @@ function _buildButtonRow(requestId: string): ColumnSetElement { { tag: "column", width: "weighted", weight: 1, elements: [denyBtn] }, ], }; + // The session-wide allow sits on its own row so its longer copy isn't + // squashed, and users don't mistake it for the single-shot approve. + return [primaryRow, allowSessionBtn]; } diff --git a/src/kernel/permission/permission-flow.ts b/src/kernel/permission/permission-flow.ts index 86d657f..e5529ae 100644 --- a/src/kernel/permission/permission-flow.ts +++ b/src/kernel/permission/permission-flow.ts @@ -82,6 +82,14 @@ export class PermissionFlow { private readonly _feishuChannels: Map; private readonly _pending = new Map(); private readonly _timeoutMs: number; + /** + * Per-session allow list populated when the user picks "allow this tool + * for the whole session" on a permission card. Keyed by `session_id`; + * each value is the set of tool names approved for that session. In-memory + * only — a kernel restart drops the list so trust never survives across + * boots. + */ + private readonly _sessionAllowlist = new Map>(); /** * Process-lifetime shared secret used by the MCP stdio subprocess to @@ -132,6 +140,19 @@ export class PermissionFlow { `Permission request for unknown channel_id=${params.channel_id}`, ); } + // Session-wide allow short-circuit: if the user has already opted to + // trust this tool for the whole session, skip the card and auto-approve. + if (this._isSessionAllowed(params.session_id, params.tool_name)) { + this._logger.info( + { session_id: params.session_id, tool_name: params.tool_name }, + "permission auto-allowed from session allowlist", + ); + return { + behavior: "allow", + updated_input: params.tool_input, + decided_by: "user", + }; + } const requestId = uuid(); const card = buildPermissionCard({ request_id: requestId, @@ -246,18 +267,31 @@ export class PermissionFlow { } const value = payload.value as unknown as PermissionCallbackValue; - const decision: "allow" | "deny" = - value?.decision === "allow" ? "allow" : "deny"; + const rawDecision = value?.decision; + const decision: "allow" | "deny" | "allow_session" = + rawDecision === "allow" || rawDecision === "allow_session" + ? rawDecision + : "deny"; this._pending.delete(payload.message_id); clearTimeout(entry.timeout); + if (decision === "allow_session") { + this._rememberSessionAllow(entry.session_id, entry.tool_name); + } + + const outcome = + decision === "allow" + ? "allowed" + : decision === "allow_session" + ? "allowed_session" + : "denied"; await this._tryUpdateCard( payload.channel_id, payload.message_id, buildPermissionResultCard({ tool_name: entry.tool_name, - outcome: decision === "allow" ? "allowed" : "denied", + outcome, decided_by_open_id: payload.operator_open_id, }), "final-result", @@ -274,13 +308,35 @@ export class PermissionFlow { ); entry.resolve({ - behavior: decision, + behavior: decision === "deny" ? "deny" : "allow", message: decision === "deny" ? "Permission denied by the user." : undefined, decided_by: "user", }); } + /** + * Forget every tool remembered for the given session. Call on session + * teardown if you want to release memory eagerly; otherwise the map is + * cleared on the next kernel restart. + */ + clearSession(sessionId: string): void { + this._sessionAllowlist.delete(sessionId); + } + + private _isSessionAllowed(sessionId: string, toolName: string): boolean { + return this._sessionAllowlist.get(sessionId)?.has(toolName) === true; + } + + private _rememberSessionAllow(sessionId: string, toolName: string): void { + let set = this._sessionAllowlist.get(sessionId); + if (!set) { + set = new Set(); + this._sessionAllowlist.set(sessionId, set); + } + set.add(toolName); + } + /** * Tag for the {@link PermissionFlow._handleCardAction} discriminator — * exported so the kernel-level router can filter by `value.action` diff --git a/tests/kernel/permission/permission-flow.test.ts b/tests/kernel/permission/permission-flow.test.ts index 05e122c..6d2b9cf 100644 --- a/tests/kernel/permission/permission-flow.test.ts +++ b/tests/kernel/permission/permission-flow.test.ts @@ -133,6 +133,111 @@ describe("PermissionFlow", () => { expect(decision.decided_by).toBe("timeout"); }); + test("allow_session remembers the tool and skips the card next time", async () => { + const { fake, channel } = _makeChannel("card_msg_session_1"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + + // First call prompts; user picks "allow for this session". + const first = flow.request({ + session_id: "s-session", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: { command: "ls" }, + }); + await Promise.resolve(); + await flow.handleDecide( + _makePayload({ + message_id: "card_msg_session_1", + operator_open_id: "ou_alice", + value: { + action: "permission_decide", + request_id: "r1", + decision: "allow_session", + }, + }), + ); + const firstDecision = await first; + expect(firstDecision.behavior).toBe("allow"); + + // A second call for the same tool on the same session must resolve + // immediately with `allow` and NOT touch the channel at all. + const cardsBefore = fake.sentCards.length; + const second = await flow.request({ + session_id: "s-session", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: { command: "pwd" }, + }); + expect(second.behavior).toBe("allow"); + expect(second.decided_by).toBe("user"); + expect(fake.sentCards.length).toBe(cardsBefore); + + // Different tool still prompts — allowlist is per-tool. + const third = flow.request({ + session_id: "s-session", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Edit", + tool_input: {}, + }); + await Promise.resolve(); + expect(fake.sentCards.length).toBe(cardsBefore + 1); + // Clean up the dangling request so the test exits promptly. + flow.clearSession("s-session"); + void third.catch(() => {}); + }); + + test("clearSession forgets previously allowed tools", async () => { + const { fake, channel } = _makeChannel("card_msg_clear_1"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + const first = flow.request({ + session_id: "s-clear", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: {}, + }); + await Promise.resolve(); + await flow.handleDecide( + _makePayload({ + message_id: "card_msg_clear_1", + operator_open_id: "ou_alice", + value: { + action: "permission_decide", + request_id: "r2", + decision: "allow_session", + }, + }), + ); + await first; + + flow.clearSession("s-clear"); + + // After clearing, the next call must prompt again (card sent). + const cardsBefore = fake.sentCards.length; + const next = flow.request({ + session_id: "s-clear", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: {}, + }); + await Promise.resolve(); + expect(fake.sentCards.length).toBe(cardsBefore + 1); + void next.catch(() => {}); + }); + test("verifyToken is constant-time and rejects bad tokens", () => { const { channel } = _makeChannel("card_msg_4"); const flow = new PermissionFlow({ From 45ae122eb86224623510259e371bb3c7e870b3e0 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 24 Apr 2026 11:26:48 +0800 Subject: [PATCH 49/69] refactor(agents): hide dummy/mock runners from user-facing listings `dummy` and `mock` are registered at boot only so tests can construct sessions without a real provider. They shouldn't appear in the /setting agent dropdown or /agent list. Add `filterUserFacingAgentTypes` and apply it in both surfaces. Selecting by explicit name still works. --- src/kernel/agents/runtime-default.ts | 12 ++++++++++++ src/kernel/commands/handlers.ts | 6 ++++-- src/kernel/setting/setting-flow.ts | 6 ++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/kernel/agents/runtime-default.ts b/src/kernel/agents/runtime-default.ts index ad0acad..6578c12 100644 --- a/src/kernel/agents/runtime-default.ts +++ b/src/kernel/agents/runtime-default.ts @@ -2,6 +2,18 @@ import { config } from "@/shared"; import { listRunnerTypes } from "./registry"; +/** + * Runner types that stay registered for tests (DummyAgentRunner / + * MockAgentRunner) but must never appear in user-facing listings like + * `/setting` or `/agent list`. Filter with `filterUserFacingAgentTypes`. + * Selecting one by explicit name still works — this is display-only. + */ +const HIDDEN_AGENT_TYPES = new Set(["dummy", "mock"]); + +export function filterUserFacingAgentTypes(types: string[]): string[] { + return types.filter((t) => !HIDDEN_AGENT_TYPES.has(t)); +} + let runtimeDefaultAgentType: string | null = null; export interface AgentRuntimeState { diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index c9e3af8..5a4397b 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { + filterUserFacingAgentTypes, getAgentRuntimeState, resetRuntimeDefaultAgentType, setRuntimeDefaultAgentType, @@ -115,9 +116,10 @@ function agentStatusReply(): CardCommandResult { : "- 配置默认:(config.yaml 未加载)", "- 影响范围:之后创建的新 session;已有 session 会继续使用创建时记录的 Agent。", ]; + const visibleTypes = filterUserFacingAgentTypes(state.availableTypes); const agentLines = - state.availableTypes.length > 0 - ? state.availableTypes.map((type) => { + visibleTypes.length > 0 + ? visibleTypes.map((type) => { const marks: string[] = []; if (type === state.activeType) marks.push("当前"); if (type === state.configuredDefaultType) marks.push("配置默认"); diff --git a/src/kernel/setting/setting-flow.ts b/src/kernel/setting/setting-flow.ts index 1d48a7f..88cdbf7 100644 --- a/src/kernel/setting/setting-flow.ts +++ b/src/kernel/setting/setting-flow.ts @@ -5,6 +5,7 @@ import { eq, like, or } from "drizzle-orm"; import type { DrizzleDB } from "@/data"; import { + filterUserFacingAgentTypes, getAgentRuntimeState, listRunnerTypes, setRuntimeDefaultAgentType, @@ -367,10 +368,11 @@ export class SettingFlow { private _renderMainCard(currentChatId: string | null): Card { const agentState = getAgentRuntimeState(); - const availableTypes = + const availableTypes = filterUserFacingAgentTypes( agentState.availableTypes.length > 0 ? agentState.availableTypes - : listRunnerTypes(); + : listRunnerTypes(), + ); const workspaces = this._workspaceStore.listWorkspaces(); const bindings = this._workspaceStore.listBindings(); const bindingCounts = new Map(); From bffea77c30362447a24f3e66452cb739581a4d34 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 24 Apr 2026 11:27:07 +0800 Subject: [PATCH 50/69] refactor(setting-card): put agent type and model on one row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the "默认 Agent" select and "Agent Model" input into a single column_set with 2:1 weighting. Model is rarely tweaked once set, so it gets the narrower column. Saves a card row and keeps the primary agent picker visually dominant. --- src/kernel/setting/setting-card.ts | 43 ++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/kernel/setting/setting-card.ts b/src/kernel/setting/setting-card.ts index 26ace70..d0bc417 100644 --- a/src/kernel/setting/setting-card.ts +++ b/src/kernel/setting/setting-card.ts @@ -326,19 +326,46 @@ function _buildConfigForm(options: SettingMainCardOptions): FormElement { width: "fill", }; + // Agent type is the primary choice (wider); Agent Model is rarely tweaked + // once set, so it gets a narrower column. + const agentRow: ColumnSetElement = { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { + tag: "column", + width: "weighted", + weight: 2, + vertical_spacing: "4px", + elements: [ + buildMarkdown("默认 Agent", { + text_size: "notation", + }), + agentSelect, + ], + }, + { + tag: "column", + width: "weighted", + weight: 1, + vertical_spacing: "4px", + elements: [ + buildMarkdown("Agent Model", { + text_size: "notation", + }), + modelInput, + ], + }, + ], + }; + return { tag: "form", name: "setting_config_form", elements: [ buildMarkdown("**全局配置**"), - buildMarkdown("默认 Agent", { - text_size: "notation", - }), - agentSelect, - buildMarkdown("Agent Model", { - text_size: "notation", - }), - modelInput, + agentRow, buildMarkdown("Codex 行为", { text_size: "notation", }), From 9cb664d34d0d5d67ca6b3bf541385679e0d8cffe Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 24 Apr 2026 16:41:17 +0800 Subject: [PATCH 51/69] fix(claude): relax country check timeout Increase the claude-gated country detection timeout to 5 seconds so slower proxy handshakes do not fail the gate prematurely. --- src/plugins/_country-check.ts | 10 +++++----- src/plugins/claude-gated.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/plugins/_country-check.ts b/src/plugins/_country-check.ts index f5306d1..0b88e5d 100644 --- a/src/plugins/_country-check.ts +++ b/src/plugins/_country-check.ts @@ -3,9 +3,9 @@ import { createLogger } from "@/shared"; const _logger = createLogger("country-check"); /** - * Endpoints that return the caller's country as a 2-letter ISO code. All - * probes fire in parallel and the first successful answer wins — any single - * endpoint being slow, rate-limited, or offline no longer stalls the gate. + * Endpoints that return the caller's country as a 2-letter ISO code. Both + * probes fire in parallel and the first successful answer wins — either + * endpoint being slow or offline no longer stalls the gate. * * `ipapi.co` was dropped: it rate-limits aggressively from shared egress * IPs, which was the main source of the 2s timeout we'd then serialize on. @@ -19,7 +19,7 @@ const COUNTRY_PROBES = [ { url: "https://api.country.is/", kind: "country_is_json" as const }, ]; -const DEFAULT_TIMEOUT_MS = 2000; +const DEFAULT_TIMEOUT_MS = 5000; /** * Race every IP-geolocation probe in parallel and return the first ISO @@ -30,7 +30,7 @@ const DEFAULT_TIMEOUT_MS = 2000; export async function detectCountry(options?: { /** Proxy URL (e.g. `http://127.0.0.1:7897`). Skipped when undefined. */ proxy?: string; - /** Per-request timeout. Defaults to 2s, matching the zshrc curl. */ + /** Per-request timeout. Defaults to 5s, matching the zshrc curl max-time. */ timeoutMs?: number; }): Promise { const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; diff --git a/src/plugins/claude-gated.ts b/src/plugins/claude-gated.ts index 3fee24b..6f89e1f 100644 --- a/src/plugins/claude-gated.ts +++ b/src/plugins/claude-gated.ts @@ -14,6 +14,7 @@ import { import { detectCountry } from "./_country-check"; const _logger = createLogger("claude-gated"); +const COUNTRY_CHECK_TIMEOUT_MS = 5000; /** * Wraps {@link ClaudeAgentRunner} with the safety preamble the user runs @@ -22,8 +23,8 @@ const _logger = createLogger("claude-gated"); * 1. Resolve a proxy URL (from `agents.env.HTTPS_PROXY` / `HTTP_PROXY`) * and use it both for the country-detection fetch and for the * delegated spawn's env. - * 2. Call the IP-geolocation probes in {@link detectCountry} with a - * short timeout. Abort the dispatch if the country is not `US` or if + * 2. Call the IP-geolocation probes in {@link detectCountry} with a 5s + * timeout. Abort the dispatch if the country is not `US` or if * every probe failed — we'd rather raise a clear error than let the * agent burn tokens against a blocked egress. * 3. Delegate to the built-in Claude runner, carrying the proxy through @@ -44,7 +45,10 @@ class ClaudeGatedRunner implements AgentRunner { ): AsyncIterableIterator { const proxy = _resolveProxy(); - const country = await detectCountry({ proxy }); + const country = await detectCountry({ + proxy, + timeoutMs: COUNTRY_CHECK_TIMEOUT_MS, + }); if (country === null) { throw new Error( "无法判定当前出口 IP 所在国家/地区,已拦截 Claude 启动(claude-gated)。", From 6bf4faf8c93e6413aad420263841959c8af2b0b5 Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 13 May 2026 16:50:04 +0800 Subject: [PATCH 52/69] feat(messaging): inline @ mention names in agent prompts and logs Feishu delivers @ mentions as opaque `@_user_N` placeholders plus a `mentions` array carrying the real open_id and name. Until now the agent (and session logs / first-message preview) only saw the placeholders, so the LLM could not tell who was being addressed. Add `inlineMentions(text, mentions)` and call it in: - Claude and Codex agent runners, before serializing the prompt - SessionLogWriter user branch + formatFileLine pure-text writer - SessionManager firstMessage preview written to the session list Kernel command matching deliberately keeps the raw placeholders, since /group, /allow, etc. resolve open_ids from `message.mentions` directly. --- .../anthropic/claude-agent-runner.ts | 3 +- src/community/openai/codex-agent-runner.ts | 3 +- src/kernel/sessioning/session-manager.ts | 13 ++++++-- .../sessioning/writers/session-log-writer.ts | 5 ++- .../writers/session-writer-utils.ts | 5 ++- src/shared/messaging/utils/index.ts | 23 +++++++++++++ tests/shared/messaging/utils.test.ts | 33 +++++++++++++++++++ 7 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/community/anthropic/claude-agent-runner.ts b/src/community/anthropic/claude-agent-runner.ts index 261a501..2babd10 100644 --- a/src/community/anthropic/claude-agent-runner.ts +++ b/src/community/anthropic/claude-agent-runner.ts @@ -8,6 +8,7 @@ import { config, createLogger, extractTextContent, + inlineMentions, type MessageContent, type ToolMessage, type AgentRunner, @@ -43,7 +44,7 @@ export class ClaudeAgentRunner implements AgentRunner { const isNew = options?.isNewSession ?? false; const signal = options?.signal; const textContentOfUserMessage = JSON.stringify( - extractTextContent(message), + inlineMentions(extractTextContent(message), message.mentions), ); const configuredModel = config.agents.default.model; diff --git a/src/community/openai/codex-agent-runner.ts b/src/community/openai/codex-agent-runner.ts index e1d9576..2031bf3 100644 --- a/src/community/openai/codex-agent-runner.ts +++ b/src/community/openai/codex-agent-runner.ts @@ -5,6 +5,7 @@ import { config, createLogger, extractTextContent, + inlineMentions, resolveInstructionFile, uuid, type ToolMessage, @@ -79,7 +80,7 @@ export class CodexAgentRunner implements AgentRunner { const signal = options?.signal; const resumeId = options.runnerSessionId ?? sessionId; const textContentOfUserMessage = JSON.stringify( - extractTextContent(message), + inlineMentions(extractTextContent(message), message.mentions), ); // Sync CLAUDE.md → AGENTS.md on every invocation so Codex CLI always diff --git a/src/kernel/sessioning/session-manager.ts b/src/kernel/sessioning/session-manager.ts index 576960b..28636f2 100644 --- a/src/kernel/sessioning/session-manager.ts +++ b/src/kernel/sessioning/session-manager.ts @@ -3,7 +3,13 @@ import { existsSync, unlinkSync } from "node:fs"; import { and, desc, eq } from "drizzle-orm"; import type { DrizzleDB } from "@/data"; -import { config, createLogger, extractTextContent, uuid } from "@/shared"; +import { + config, + createLogger, + extractTextContent, + inlineMentions, + uuid, +} from "@/shared"; import type { Session as SessionEntity, UserMessage } from "@/shared"; import { getRuntimeDefaultAgentType, resolveAgentTypeAlias } from "../agents"; @@ -159,7 +165,10 @@ export class SessionManager { if (options?.firstMessage) { this._updateFirstMessage( sessionId, - extractTextContent(options.firstMessage), + inlineMentions( + extractTextContent(options.firstMessage), + options.firstMessage.mentions, + ), ); } diff --git a/src/kernel/sessioning/writers/session-log-writer.ts b/src/kernel/sessioning/writers/session-log-writer.ts index 7df2d95..4a416eb 100644 --- a/src/kernel/sessioning/writers/session-log-writer.ts +++ b/src/kernel/sessioning/writers/session-log-writer.ts @@ -1,6 +1,7 @@ import { createLogger, extractTextContent, + inlineMentions, type Logger, type Message, } from "@/shared"; @@ -27,7 +28,9 @@ export class SessionLogWriter implements SessionWriter { this._logger.debug(`SYSTEM: ${message.subtype}`); break; case "user": - this._logger.debug(`USER: ${extractTextContent(message)}`); + this._logger.debug( + `USER: ${inlineMentions(extractTextContent(message), message.mentions)}`, + ); break; case "assistant": this._logger.debug( diff --git a/src/kernel/sessioning/writers/session-writer-utils.ts b/src/kernel/sessioning/writers/session-writer-utils.ts index 3cb2136..e17f72b 100644 --- a/src/kernel/sessioning/writers/session-writer-utils.ts +++ b/src/kernel/sessioning/writers/session-writer-utils.ts @@ -9,6 +9,7 @@ import { dirname } from "node:path"; import { logger, extractTextContent, + inlineMentions, isPureTextMessage, type Message, } from "@/shared"; @@ -52,6 +53,8 @@ export function formatFileLine(message: Message): string | null { if (!isPureTextMessage(message)) { return null; } - const text = extractTextContent(message); + const raw = extractTextContent(message); + const text = + message.role === "user" ? inlineMentions(raw, message.mentions) : raw; return `${message.role.toUpperCase()}:\n${text}`; } diff --git a/src/shared/messaging/utils/index.ts b/src/shared/messaging/utils/index.ts index 93f9813..0bc10bb 100644 --- a/src/shared/messaging/utils/index.ts +++ b/src/shared/messaging/utils/index.ts @@ -4,6 +4,7 @@ import type { GlobToolUseMessageContent, GrepToolUseMessageContent, Message, + MessageMention, ReadToolUseMessageContent, SkillToolUseMessageContent, ToolSearchToolUseMessageContent, @@ -85,6 +86,28 @@ export function extractTextContent( return result.join("\n\n").trim(); } +/** + * Replace placeholder `key`s with `@` for each provided mention. + * + * Feishu delivers @-mentions as opaque `@_user_N` tokens in the message + * text plus a sibling `mentions` array carrying the real `open_id` and + * `name`. Agent runners call this before serializing the prompt so the + * underlying LLM sees `@xluos` instead of `@_user_0`. Keys without a + * known name are left as-is. + */ +export function inlineMentions( + text: string, + mentions: MessageMention[] | undefined, +): string { + if (!mentions || mentions.length === 0) return text; + let result = text; + for (const mention of mentions) { + if (!mention.name) continue; + result = result.replaceAll(mention.key, `@${mention.name}`); + } + return result; +} + function extractToolUse(content: ToolUseMessageContent): string { if (content.name === "Task") { const toolUse = content as ToolUseMessageContent; diff --git a/tests/shared/messaging/utils.test.ts b/tests/shared/messaging/utils.test.ts index 9b32ec7..69a1d0c 100644 --- a/tests/shared/messaging/utils.test.ts +++ b/tests/shared/messaging/utils.test.ts @@ -4,6 +4,7 @@ import { containsThinking, containsToolUse, extractTextContent, + inlineMentions, isPureTextMessage, } from "@/shared"; import type { Message } from "@/shared"; @@ -214,3 +215,35 @@ describe("extractTextContent", () => { expect(extractTextContent(msg)).toBe(""); }); }); + +describe("inlineMentions", () => { + test("returns text unchanged when mentions list is empty or missing", () => { + expect(inlineMentions("hi @_user_0", [])).toBe("hi @_user_0"); + expect(inlineMentions("hi @_user_0", undefined)).toBe("hi @_user_0"); + }); + + test("replaces placeholders with @", () => { + const result = inlineMentions( + "@_user_0 ping @_user_1 and @_user_0 again", + [ + { key: "@_user_0", open_id: "ou_aaa", name: "Alice" }, + { key: "@_user_1", open_id: "ou_bbb", name: "Bob" }, + ], + ); + expect(result).toBe("@Alice ping @Bob and @Alice again"); + }); + + test("leaves placeholder as-is when name is missing", () => { + const result = inlineMentions("@_user_0 hi", [ + { key: "@_user_0", open_id: "ou_aaa" }, + ]); + expect(result).toBe("@_user_0 hi"); + }); + + test("leaves placeholders unknown to the mentions array as-is", () => { + const result = inlineMentions("@_user_0 and @_user_9", [ + { key: "@_user_0", open_id: "ou_aaa", name: "Alice" }, + ]); + expect(result).toBe("@Alice and @_user_9"); + }); +}); From 1db8a59e7127e3dcd65d8ddb854d9d8e5ee0cffe Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 13 May 2026 17:25:37 +0800 Subject: [PATCH 53/69] feat(setting): gate /setting panel behind admin_open_ids allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit admin allowlist for the `/setting` interactive panel. `setting.admin_open_ids` in config.yaml defaults to an empty list, which keeps the existing channel-level whitelist as the sole gate (backward compatible). When non-empty, both `SettingFlow.start` and every `setting_*` card action are rejected for users not in the list — the text rejection for the slash command and a no-back result card for stale card clicks. Why: the panel exposes destructive actions (workspace delete, global config rewrite) that the channel whitelist doesn't differentiate from ordinary chat messages, so deployments with multiple whitelisted users need a finer-grained gate. --- src/kernel/setting/setting-flow.ts | 43 ++++++++++++++++++++++++++++++ src/shared/config/index.ts | 11 ++++++++ src/shared/config/schema.ts | 16 +++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/kernel/setting/setting-flow.ts b/src/kernel/setting/setting-flow.ts index 88cdbf7..ceac0b9 100644 --- a/src/kernel/setting/setting-flow.ts +++ b/src/kernel/setting/setting-flow.ts @@ -74,6 +74,17 @@ export class SettingFlow { await this._replyText(message, "❌ /setting 需要飞书会话上下文。"); return; } + if (!this._isAdmin(message.sender_open_id)) { + this._logger.info( + { chat_id: chatId, sender_open_id: message.sender_open_id }, + "rejected /setting from non-admin", + ); + await this._replyText( + message, + "🚫 你没有 /setting 权限。请联系管理员把你的 open_id 加到 `setting.admin_open_ids`。", + ); + return; + } const channel = this._feishuChannels.get(message.channel_id); if (!channel) { await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); @@ -108,6 +119,26 @@ export class SettingFlow { ); return; } + if (!this._isAdmin(payload.operator_open_id)) { + this._logger.info( + { + action_name: payload.action_name, + operator_open_id: payload.operator_open_id, + }, + "rejected /setting card action from non-admin", + ); + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingResultCard( + "🚫 你没有 /setting 权限,无法操作此卡片。", + [], + { show_back: false }, + ), + "non-admin", + ); + return; + } try { switch (payload.action_name) { case SETTING_ACTION.saveConfig: @@ -441,6 +472,18 @@ export class SettingFlow { } } + /** + * Allowlist gate for `/setting`. Empty `setting.admin_open_ids` means no + * restriction (every channel-allowed user may use the panel); otherwise + * the sender's open_id must be in the list. Missing `senderOpenId` is + * always rejected once a non-empty list is configured. + */ + private _isAdmin(senderOpenId: string | undefined | null): boolean { + const adminIds = config.setting.admin_open_ids; + if (adminIds.length === 0) return true; + return !!senderOpenId && adminIds.includes(senderOpenId); + } + private async _replyText(message: UserMessage, text: string): Promise { if (!message.channel_id) return; const channel = this._feishuChannels.get(message.channel_id); diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index 944c3ea..76a07da 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -14,6 +14,7 @@ export type { ChannelParams, CodexConfig, MessagingConfig, + SettingConfig, TaskingConfig, } from "./schema"; @@ -91,5 +92,15 @@ export const config = { } return _appConfig.messaging; }, + get setting() { + if (!_appConfig) { + // Fall back to the schema default ({ admin_open_ids: [] }) when the + // YAML hasn't been loaded yet (e.g. in early boot / unit tests). This + // keeps the gate permissive instead of throwing — the gate is a + // safety check, not load-bearing for boot. + return { admin_open_ids: [] as string[] }; + } + return _appConfig.setting; + }, paths, }; diff --git a/src/shared/config/schema.ts b/src/shared/config/schema.ts index cfe1d2c..c1296ff 100644 --- a/src/shared/config/schema.ts +++ b/src/shared/config/schema.ts @@ -99,6 +99,21 @@ export const MessagingConfig = z.object({ }); export interface MessagingConfig extends z.infer {} +/** + * Configuration for the `/setting` admin panel. + * + * `admin_open_ids` is an explicit allowlist for who may open the panel and + * interact with its callback buttons. Empty (the default) means "no + * restriction" — backward compatible with existing deployments where the + * channel-level whitelist already gates inbound traffic. Non-empty switches + * to strict mode: only listed open_ids may run `/setting` and click on its + * cards; everyone else gets a plain-text rejection. + */ +export const SettingConfig = z.object({ + admin_open_ids: z.array(z.string()).default([]), +}); +export interface SettingConfig extends z.infer {} + /** * Top-level application configuration loaded from config.yaml. * @@ -113,5 +128,6 @@ export const AppConfig = z.object({ agents: AgentsConfig, tasking: TaskingConfig, messaging: MessagingConfig, + setting: SettingConfig.default({ admin_open_ids: [] }), }); export interface AppConfig extends z.infer {} From 2016072d022c802ee0df4c921c31cca3b24f2924 Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 13 May 2026 17:26:46 +0800 Subject: [PATCH 54/69] feat(repos): add /repos command for managing REPOS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an interactive `/repos` panel that lists every entry in `$AGENTARA_HOME/REPOS.md` and lets the user add, edit, or delete sections without hand-editing the file. - `repos-writer` mutates sections in-place: name-matched lookup skips H2 headings inside `` blocks (mirroring the reader), edits preserve non-bullet prose inside the section, deletes remove the section and collapse blank-line runs. - `repos-card` renders a main listing with inline edit/delete buttons, separate add/edit form cards, and a delete-confirm card. Every card carries the same "返回 + 关闭" affordance as the setup/setting cards. - `repos-flow` is stateless: every card action carries the `repo_name` it targets, so stale cards from before a restart still behave correctly. - Edit mode locks the name (rename = delete + add) so prose context inside the section can't be silently dropped. Wired into the kernel like `/setting`: kernel routes `/repos` to `ReposFlow.start` and any `repos_*` action to `ReposFlow.handleAction`. `/help` gains an entry under "仓库操作". --- src/kernel/commands/handlers.ts | 1 + src/kernel/kernel.ts | 20 ++ src/kernel/repos/index.ts | 2 + src/kernel/repos/repos-card.ts | 429 +++++++++++++++++++++++++++++++ src/kernel/repos/repos-flow.ts | 374 +++++++++++++++++++++++++++ src/kernel/repos/repos-writer.ts | 166 ++++++++++++ 6 files changed, 992 insertions(+) create mode 100644 src/kernel/repos/index.ts create mode 100644 src/kernel/repos/repos-card.ts create mode 100644 src/kernel/repos/repos-flow.ts create mode 100644 src/kernel/repos/repos-writer.ts diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 5a4397b..206e358 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -683,6 +683,7 @@ const HELP_GROUPS: HelpGroup[] = [ { usage: "/sync", note: "对当前 workspace 下每个仓库 fetch + 快进拉取" }, { usage: "/clone [别名]", note: "克隆仓库到当前群 workspace" }, { usage: "/checkout <分支>", note: "切换当前活跃仓库的分支" }, + { usage: "/repos", note: "管理 REPOS.md 中的预设仓库目录" }, ], }, { diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 8fdbd21..9dd28a0 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -46,6 +46,7 @@ import { buildCommandCard } from "./commands/cards"; import { GroupFlow } from "./group/group-flow"; import { MultiChannelMessageGateway } from "./messaging"; import { PERMISSION_ACTION, PermissionFlow } from "./permission"; +import { ReposFlow } from "./repos"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; import { SettingFlow } from "./setting/setting-flow"; @@ -72,6 +73,7 @@ class Kernel { private _setupFlow!: SetupFlow; private _switchFlow!: SwitchFlow; private _settingFlow!: SettingFlow; + private _reposFlow!: ReposFlow; private _groupFlow!: GroupFlow; private _permissionFlow!: PermissionFlow; private _codexResumeRestarts = new Map< @@ -89,6 +91,7 @@ class Kernel { this._initSetupFlow(); this._initSwitchFlow(); this._initSettingFlow(); + this._initReposFlow(); this._initGroupFlow(); this._initPermissionFlow(); this._initServer(); @@ -220,6 +223,12 @@ class Kernel { }); } + private _initReposFlow(): void { + this._reposFlow = new ReposFlow({ + feishuChannels: this._feishuChannels, + }); + } + private _initPermissionFlow(): void { this._permissionFlow = new PermissionFlow({ feishuChannels: this._feishuChannels, @@ -303,6 +312,13 @@ class Kernel { return; } + // Handle /repos command (kernel-owned — interactive card to manage + // REPOS.md entries the same way /setting manages workspaces). + if (text === "/repos") { + await this._reposFlow.start(message); + return; + } + // Handle /group command (kernel-owned — orchestrates create-chat + // transfer-owner + auto /setup). Takes args, so match the prefix rather // than equality. @@ -585,6 +601,10 @@ class Kernel { await this._settingFlow.handleAction(payload); return; } + if (payload.action_name.startsWith("repos_")) { + await this._reposFlow.handleAction(payload); + return; + } this._logger.warn( { action_name: payload.action_name, message_id: payload.message_id }, "unhandled card action", diff --git a/src/kernel/repos/index.ts b/src/kernel/repos/index.ts new file mode 100644 index 0000000..7c47034 --- /dev/null +++ b/src/kernel/repos/index.ts @@ -0,0 +1,2 @@ +export { REPOS_ACTION, REPOS_FIELD } from "./repos-card"; +export { ReposFlow } from "./repos-flow"; diff --git a/src/kernel/repos/repos-card.ts b/src/kernel/repos/repos-card.ts new file mode 100644 index 0000000..74b323e --- /dev/null +++ b/src/kernel/repos/repos-card.ts @@ -0,0 +1,429 @@ +import type { PredefinedRepo } from "@/shared"; + +import type { + ButtonElement, + Card, + ColumnSetElement, + Element, + FormElement, + InputElement, +} from "../../community/feishu/messaging/types"; +import { + buildCardIntro, + buildDismissedCard, + buildMarkdown, + buildResultCard, + buildSectionBlock, +} from "../setup/card-ui"; + +/** + * Action discriminators for `/repos`. Kernel forwards any card action whose + * name starts with `repos_` to `ReposFlow.handleAction`. + */ +export const REPOS_ACTION = { + openAdd: "repos_open_add", + openEdit: "repos_open_edit", + openDelete: "repos_open_delete", + addSubmit: "repos_add_submit", + editSubmit: "repos_edit_submit", + deleteApply: "repos_delete_apply", + back: "repos_back", + dismiss: "repos_dismiss", +} as const; + +/** + * Form field names for the add/edit forms. The two forms share input names + * so the submit handler can treat them uniformly — the discriminator is the + * button name (`addSubmit` vs `editSubmit`). + */ +export const REPOS_FIELD = { + name: "repo_name", + gitUrl: "repo_git_url", + description: "repo_description", +} as const; + +export interface ReposMainCardOptions { + repos: PredefinedRepo[]; + /** Path string surfaced in the subtitle so users know where the file lives. */ + file_path: string; +} + +/** + * Top-level `/repos` panel: lists every repo currently in `REPOS.md` with + * inline edit/delete buttons plus a "添加仓库" button at the bottom. + */ +export function buildReposMainCard(options: ReposMainCardOptions): Card { + const { repos, file_path } = options; + const elements: Element[] = [ + buildCardIntro({ + title: "REPOS.md 仓库管理", + subtitle: `${file_path} · ${repos.length} 个仓库`, + }), + ]; + + if (repos.length === 0) { + elements.push( + buildMarkdown( + "REPOS.md 里还没有任何仓库条目。点击下方「添加仓库」新增一个。", + ), + ); + } else { + for (const repo of repos) { + elements.push(_buildRepoRow(repo)); + } + } + + elements.push(_buildMainActionRow()); + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: "📚 REPOS.md 管理" }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +export interface ReposFormCardOptions { + /** + * Edit mode pre-fills the form with the current values and disables the + * `name` input (rename = delete + add). Add mode renders an empty form + * with the name input enabled. + */ + mode: "add" | "edit"; + initial?: PredefinedRepo; +} + +/** + * Add / edit form card. Both modes share the same input layout — the + * submit button is the only thing that changes, so the handler can route + * on its name (`addSubmit` vs `editSubmit`). + */ +export function buildReposFormCard(options: ReposFormCardOptions): Card { + const { mode, initial } = options; + const title = mode === "add" ? "添加仓库" : `编辑仓库 ${initial?.name ?? ""}`; + + const nameInput: InputElement = { + tag: "input", + name: REPOS_FIELD.name, + placeholder: { tag: "plain_text", content: "例如 agentara" }, + default_value: initial?.name, + width: "fill", + }; + const gitUrlInput: InputElement = { + tag: "input", + name: REPOS_FIELD.gitUrl, + placeholder: { tag: "plain_text", content: "git@... 或 https://..." }, + default_value: initial?.git_url, + width: "fill", + }; + const descInput: InputElement = { + tag: "input", + name: REPOS_FIELD.description, + placeholder: { tag: "plain_text", content: "一句话描述(可选)" }, + default_value: initial?.description, + width: "fill", + }; + + const submitBtn: ButtonElement = { + tag: "button", + name: + mode === "add" + ? REPOS_ACTION.addSubmit + : REPOS_ACTION.editSubmit, + text: { tag: "plain_text", content: mode === "add" ? "保存" : "保存修改" }, + type: "primary", + action_type: "form_submit", + width: "fill", + }; + + const formElements: Element[] = [ + buildMarkdown("**名称**"), + ]; + if (mode === "edit") { + // Renaming = delete + add, which would orphan any prose context inside + // the section. Surface the name as static text in edit mode to make + // that constraint obvious instead of pretending it's editable. + formElements.push( + buildMarkdown(`\`${initial?.name ?? ""}\``), + buildMarkdown( + "编辑模式下名称不可改;改名请先删除再添加。", + { text_size: "notation" }, + ), + ); + } else { + formElements.push(nameInput); + } + formElements.push( + buildMarkdown("**git_url**"), + gitUrlInput, + buildMarkdown("**描述(可选)**"), + descInput, + submitBtn, + ); + + const form: FormElement = { + tag: "form", + name: mode === "add" ? "repos_add_form" : "repos_edit_form", + elements: formElements, + }; + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: `📚 ${title}` }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements: [ + buildCardIntro({ title }), + form, + _buildBackDismissRow(), + ], + }, + }; +} + +export interface ReposDeleteConfirmCardOptions { + repo: PredefinedRepo; +} + +export function buildReposDeleteConfirmCard( + options: ReposDeleteConfirmCardOptions, +): Card { + const { repo } = options; + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: `⚠️ 删除 ${repo.name}` }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements: [ + buildCardIntro({ + title: "⚠️ 确认删除仓库条目", + subtitle: repo.name, + }), + ...buildSectionBlock({ + title: "将从 REPOS.md 移除", + lines: [ + `- 名称:\`${repo.name}\``, + `- git_url:\`${repo.git_url}\``, + repo.description + ? `- 描述:${repo.description}` + : "- 描述:(未填)", + "- 已克隆的本地仓库不受影响。", + ], + }), + _buildDeleteConfirmRow(repo.name), + ], + }, + }; +} + +export function buildReposResultCard( + summary: string, + detail: string[] = [], +): Card { + const card = buildResultCard({ + title: "REPOS.md", + summary, + detail, + }); + card.body.elements.push(_buildBackDismissRow()); + return card; +} + +export function buildReposDismissedCard(): Card { + return buildDismissedCard({ title: "REPOS.md 仓库管理" }); +} + +function _buildRepoRow(repo: PredefinedRepo): ColumnSetElement { + const desc = repo.description?.trim(); + const lines = [ + `**${repo.name}**`, + `\`${repo.git_url}\``, + ]; + if (desc) lines.push(`${desc}`); + + const editBtn: ButtonElement = { + tag: "button", + name: `repos_edit_btn_${repo.name}`, + text: { tag: "plain_text", content: "编辑" }, + type: "default", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: REPOS_ACTION.openEdit, repo_name: repo.name }, + }, + ], + }; + const deleteBtn: ButtonElement = { + tag: "button", + name: `repos_delete_btn_${repo.name}`, + text: { tag: "plain_text", content: "删除" }, + type: "danger", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: REPOS_ACTION.openDelete, repo_name: repo.name }, + }, + ], + }; + + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { + tag: "column", + width: "weighted", + weight: 3, + vertical_align: "center", + elements: [buildMarkdown(lines.join("\n"))], + }, + { + tag: "column", + width: "170px", + vertical_align: "center", + elements: [ + { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "4px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [editBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [deleteBtn] }, + ], + }, + ], + }, + ], + }; +} + +function _buildMainActionRow(): ColumnSetElement { + const addBtn: ButtonElement = { + tag: "button", + name: "repos_open_add_btn", + text: { tag: "plain_text", content: "添加仓库" }, + type: "primary", + width: "fill", + behaviors: [ + { type: "callback", value: { action: REPOS_ACTION.openAdd } }, + ], + }; + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [addBtn] }, + { + tag: "column", + width: "weighted", + weight: 1, + elements: [_buildDismissButton()], + }, + ], + }; +} + +function _buildBackDismissRow(): ColumnSetElement { + const backBtn: ButtonElement = { + tag: "button", + name: "repos_back_btn", + text: { tag: "plain_text", content: "← 返回" }, + type: "default", + width: "fill", + behaviors: [ + { type: "callback", value: { action: REPOS_ACTION.back } }, + ], + }; + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [backBtn] }, + { + tag: "column", + width: "weighted", + weight: 1, + elements: [_buildDismissButton()], + }, + ], + }; +} + +function _buildDeleteConfirmRow(repoName: string): ColumnSetElement { + const cancelBtn: ButtonElement = { + tag: "button", + name: "repos_delete_cancel_btn", + text: { tag: "plain_text", content: "← 返回" }, + type: "default", + width: "fill", + behaviors: [ + { type: "callback", value: { action: REPOS_ACTION.back } }, + ], + }; + const confirmBtn: ButtonElement = { + tag: "button", + name: "repos_delete_apply_btn", + text: { tag: "plain_text", content: "确认删除" }, + type: "danger", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: REPOS_ACTION.deleteApply, repo_name: repoName }, + }, + ], + }; + return { + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [cancelBtn] }, + { + tag: "column", + width: "weighted", + weight: 1, + elements: [_buildDismissButton()], + }, + { tag: "column", width: "weighted", weight: 1, elements: [confirmBtn] }, + ], + }; +} + +function _buildDismissButton(): ButtonElement { + return { + tag: "button", + name: "repos_dismiss_btn", + text: { tag: "plain_text", content: "关闭" }, + type: "default", + width: "fill", + behaviors: [ + { type: "callback", value: { action: REPOS_ACTION.dismiss } }, + ], + }; +} diff --git a/src/kernel/repos/repos-flow.ts b/src/kernel/repos/repos-flow.ts new file mode 100644 index 0000000..0ccc58c --- /dev/null +++ b/src/kernel/repos/repos-flow.ts @@ -0,0 +1,374 @@ +import type { Logger } from "@/shared"; +import { + config, + createLogger, + loadPredefinedRepos, + type CardActionPayload, + type PredefinedRepo, + type UserMessage, +} from "@/shared"; + +import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; +import type { Card } from "../../community/feishu/messaging/types"; + +import { + buildReposDeleteConfirmCard, + buildReposDismissedCard, + buildReposFormCard, + buildReposMainCard, + buildReposResultCard, + REPOS_ACTION, + REPOS_FIELD, +} from "./repos-card"; +import { addRepo, editRepo, removeRepo } from "./repos-writer"; + +/** + * Stateful orchestrator for the `/repos` interactive flow. + * + * Like `/setting`, this flow doesn't track pending cards in memory — every + * card-action payload that needs context (edit/delete target) carries the + * `repo_name` it applies to, so handlers can re-read REPOS.md and operate + * statelessly. A stale card from before a restart simply re-reads the + * latest file state on the next click. + */ +export class ReposFlow { + private readonly _logger: Logger = createLogger("repos-flow"); + private readonly _feishuChannels: Map; + + constructor(deps: { + feishuChannels: Map; + }) { + this._feishuChannels = deps.feishuChannels; + } + + /** Entry point for `/repos`. */ + async start(message: UserMessage): Promise { + const chatId = message.chat_id; + if (!chatId || !message.channel_id) { + await this._replyText(message, "❌ /repos 需要飞书会话上下文。"); + return; + } + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) { + await this._replyText(message, "❌ 无法找到对应的飞书 channel。"); + return; + } + const card = this._renderMainCard(); + try { + await channel.sendRawCard(chatId, card, { + replyTo: message.id, + replyInThread: false, + }); + } catch (err) { + this._logger.error( + { err, chat_id: chatId }, + "failed to send repos main card", + ); + await this._replyText( + message, + `❌ 渲染 /repos 卡片失败:${(err as Error).message}`, + ); + } + } + + /** Single entry point for every `repos_*` card action. */ + async handleAction(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id, action_name: payload.action_name }, + "repos action for unknown channel", + ); + return; + } + try { + switch (payload.action_name) { + case REPOS_ACTION.openAdd: + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposFormCard({ mode: "add" }), + "open-add", + ); + return; + case REPOS_ACTION.openEdit: + await this._handleOpenEdit(channel, payload); + return; + case REPOS_ACTION.openDelete: + await this._handleOpenDelete(channel, payload); + return; + case REPOS_ACTION.addSubmit: + await this._handleAddSubmit(channel, payload); + return; + case REPOS_ACTION.editSubmit: + await this._handleEditSubmit(channel, payload); + return; + case REPOS_ACTION.deleteApply: + await this._handleDeleteApply(channel, payload); + return; + case REPOS_ACTION.back: + await this._tryUpdateCard( + channel, + payload.message_id, + this._renderMainCard(), + "back", + ); + return; + case REPOS_ACTION.dismiss: + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposDismissedCard(), + "dismiss", + ); + return; + default: + this._logger.warn( + { action_name: payload.action_name }, + "unknown repos action", + ); + } + } catch (err) { + this._logger.error( + { err, action_name: payload.action_name }, + "repos action failed", + ); + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`❌ 操作失败:${(err as Error).message}`), + "action-error", + ); + } + } + + private async _handleOpenEdit( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const repoName = _readRepoName(payload); + if (!repoName) return; + const repo = _findRepo(repoName); + if (!repo) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`⚠️ 仓库 \`${repoName}\` 已不存在。`), + "edit-missing", + ); + return; + } + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposFormCard({ mode: "edit", initial: repo }), + "open-edit", + ); + } + + private async _handleOpenDelete( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const repoName = _readRepoName(payload); + if (!repoName) return; + const repo = _findRepo(repoName); + if (!repo) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`⚠️ 仓库 \`${repoName}\` 已不存在。`), + "delete-missing", + ); + return; + } + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposDeleteConfirmCard({ repo }), + "open-delete", + ); + } + + private async _handleAddSubmit( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const name = _readField(payload, REPOS_FIELD.name).trim(); + const gitUrl = _readField(payload, REPOS_FIELD.gitUrl).trim(); + const description = _readField(payload, REPOS_FIELD.description).trim(); + if (!name || !gitUrl) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard("❌ 名称和 git_url 都不能为空。"), + "add-invalid", + ); + return; + } + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard( + `❌ 名称 \`${name}\` 不合法,只允许字母、数字、\`.\`、\`_\`、\`-\`。`, + ), + "add-invalid-name", + ); + return; + } + const result = addRepo({ name, git_url: gitUrl, description }); + if (!result.ok) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`❌ ${result.reason ?? "写入失败"}`), + "add-failed", + ); + return; + } + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard( + `✅ 已添加 \`${name}\`。`, + [ + `- git_url:\`${gitUrl}\``, + description ? `- 描述:${description}` : "- 描述:(未填)", + ], + ), + "add-ok", + ); + this._logger.info({ name, git_url: gitUrl }, "repos: added"); + } + + private async _handleEditSubmit( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const name = _readField(payload, REPOS_FIELD.name).trim(); + const gitUrl = _readField(payload, REPOS_FIELD.gitUrl).trim(); + const description = _readField(payload, REPOS_FIELD.description).trim(); + if (!name) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard("❌ 未能识别要编辑的仓库名。"), + "edit-missing-name", + ); + return; + } + if (!gitUrl) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard("❌ git_url 不能为空。"), + "edit-invalid", + ); + return; + } + const result = editRepo(name, { git_url: gitUrl, description }); + if (!result.ok) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`❌ ${result.reason ?? "写入失败"}`), + "edit-failed", + ); + return; + } + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard( + `✅ 已更新 \`${name}\`。`, + [ + `- git_url:\`${gitUrl}\``, + description ? `- 描述:${description}` : "- 描述:(未填)", + ], + ), + "edit-ok", + ); + this._logger.info({ name, git_url: gitUrl }, "repos: edited"); + } + + private async _handleDeleteApply( + channel: FeishuMessageChannel, + payload: CardActionPayload, + ): Promise { + const repoName = _readRepoName(payload); + if (!repoName) return; + const result = removeRepo(repoName); + if (!result.ok) { + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`❌ ${result.reason ?? "删除失败"}`), + "delete-failed", + ); + return; + } + await this._tryUpdateCard( + channel, + payload.message_id, + buildReposResultCard(`✅ 已删除 \`${repoName}\`。`), + "delete-ok", + ); + this._logger.info({ name: repoName }, "repos: removed"); + } + + private _renderMainCard(): Card { + return buildReposMainCard({ + repos: loadPredefinedRepos(), + file_path: config.paths.repos_md, + }); + } + + private async _tryUpdateCard( + channel: FeishuMessageChannel, + messageId: string, + card: Card, + stage: string, + ): Promise { + try { + await channel.updateRawCard(messageId, card); + } catch (err) { + this._logger.error( + { err, stage, message_id: messageId }, + "repos updateRawCard failed", + ); + } + } + + private async _replyText( + message: UserMessage, + text: string, + ): Promise { + if (!message.channel_id) return; + const channel = this._feishuChannels.get(message.channel_id); + if (!channel) return; + await channel.replyMessage( + message.id, + { + role: "assistant", + session_id: message.session_id, + content: [{ type: "text", text }], + }, + { streaming: false, replyInThread: false }, + ); + } +} + +function _readRepoName(payload: CardActionPayload): string | null { + const name = payload.value.repo_name; + return typeof name === "string" && name ? name : null; +} + +function _readField(payload: CardActionPayload, key: string): string { + const raw = payload.form_value[key]; + return typeof raw === "string" ? raw : ""; +} + +function _findRepo(name: string): PredefinedRepo | null { + return loadPredefinedRepos().find((r) => r.name === name) ?? null; +} diff --git a/src/kernel/repos/repos-writer.ts b/src/kernel/repos/repos-writer.ts new file mode 100644 index 0000000..7417377 --- /dev/null +++ b/src/kernel/repos/repos-writer.ts @@ -0,0 +1,166 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +import { config } from "@/shared"; + +/** + * Boundary of a single `## ` section in `REPOS.md`. `start` is the + * 0-based line index of the heading itself; `end` is the line index AFTER + * the section (i.e. the next H2 line or `lines.length`). Sections nested + * inside `` blocks are skipped by `findSection` — only real + * repo entries are mutated. + */ +interface SectionRange { + start: number; + end: number; +} + +/** + * Inputs for `addRepo` / `editRepo`. `description` is optional — REPOS.md + * allows missing description bullets. + */ +export interface RepoInput { + name: string; + git_url: string; + description?: string; +} + +export interface WriteResult { + ok: boolean; + reason?: string; +} + +/** + * Append a new repo section to `REPOS.md`. Fails fast when a section with + * the same name already exists so the user has to delete it first instead + * of silently overwriting context they accumulated under the existing + * heading. + */ +export function addRepo(input: RepoInput): WriteResult { + const raw = _readFile(); + if (_findSection(raw, input.name)) { + return { ok: false, reason: `仓库 \`${input.name}\` 已存在,请先编辑或删除。` }; + } + const trimmed = raw.replace(/\s+$/, ""); + const block = _formatSection(input); + const next = trimmed.length > 0 ? `${trimmed}\n\n${block}\n` : `${block}\n`; + writeFileSync(config.paths.repos_md, next, "utf-8"); + return { ok: true }; +} + +/** + * Update the `git_url` and/or `description` bullets of an existing section. + * Non-bullet content (free-form agent context) inside the section is + * preserved verbatim — only the two structured bullet lines are touched. + * Returns `{ ok: false }` when the section doesn't exist. + */ +export function editRepo( + name: string, + patch: { git_url?: string; description?: string }, +): WriteResult { + const raw = _readFile(); + const range = _findSection(raw, name); + if (!range) { + return { ok: false, reason: `仓库 \`${name}\` 不存在。` }; + } + const lines = raw.split(/\r?\n/); + const section = lines.slice(range.start, range.end); + let gitTouched = false; + let descTouched = false; + for (let i = 0; i < section.length; i += 1) { + const line = section[i]!; + if (patch.git_url !== undefined && /^\s*-\s*git_url\s*:/.test(line)) { + section[i] = `- git_url: ${patch.git_url}`; + gitTouched = true; + } + if (patch.description !== undefined && /^\s*-\s*description\s*:/.test(line)) { + section[i] = `- description: ${patch.description}`; + descTouched = true; + } + } + // Bullets that didn't exist before get appended right after the heading + // so they land at the canonical spot for the next read. + const headerIdx = 0; + const insertions: string[] = []; + if (patch.git_url !== undefined && !gitTouched) { + insertions.push(`- git_url: ${patch.git_url}`); + } + if (patch.description !== undefined && !descTouched) { + insertions.push(`- description: ${patch.description}`); + } + if (insertions.length > 0) { + section.splice(headerIdx + 1, 0, "", ...insertions); + } + const next = [ + ...lines.slice(0, range.start), + ...section, + ...lines.slice(range.end), + ].join("\n"); + writeFileSync(config.paths.repos_md, next, "utf-8"); + return { ok: true }; +} + +/** + * Remove a section in its entirety. Trailing whitespace between adjacent + * sections is collapsed so the file stays tidy across repeated edits. + */ +export function removeRepo(name: string): WriteResult { + const raw = _readFile(); + const range = _findSection(raw, name); + if (!range) { + return { ok: false, reason: `仓库 \`${name}\` 不存在。` }; + } + const lines = raw.split(/\r?\n/); + const next = [ + ...lines.slice(0, range.start), + ...lines.slice(range.end), + ] + .join("\n") + .replace(/\n{3,}/g, "\n\n"); + writeFileSync(config.paths.repos_md, next, "utf-8"); + return { ok: true }; +} + +function _readFile(): string { + if (!existsSync(config.paths.repos_md)) return ""; + return readFileSync(config.paths.repos_md, "utf-8"); +} + +function _formatSection(repo: RepoInput): string { + const desc = repo.description?.trim(); + const bullets = [`- git_url: ${repo.git_url}`]; + if (desc) bullets.push(`- description: ${desc}`); + return [`## ${repo.name}`, "", ...bullets].join("\n"); +} + +/** + * Locate a section by name while ignoring H2 headings that live inside + * `` comment blocks. Mirrors the comment-skipping logic in + * `loadPredefinedRepos` so writer and reader stay in lockstep. + */ +function _findSection(raw: string, name: string): SectionRange | null { + const lines = raw.split(/\r?\n/); + const target = name.trim(); + let inHtmlComment = false; + let start = -1; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]!; + if (inHtmlComment) { + if (line.includes("-->")) inHtmlComment = false; + continue; + } + if (line.trim().startsWith("")) { + inHtmlComment = true; + continue; + } + const h2 = /^##\s+(.+?)\s*$/.exec(line); + if (!h2) continue; + if (start === -1) { + if (h2[1]!.trim() === target) start = i; + continue; + } + // Second H2 (after we've found ours) terminates the section. + return { start, end: i }; + } + if (start === -1) return null; + return { start, end: lines.length }; +} From babeb4a2e956114739d39191f01efe47829eccb4 Mon Sep 17 00:00:00 2001 From: xluos Date: Wed, 13 May 2026 17:27:52 +0800 Subject: [PATCH 55/69] feat(setup,setting): add dismiss button and allow zero-repo setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds a "关闭" callback button on every setup/setting card. Clicking it swaps the card for a no-button dismissed state via `updateRawCard` so the form elements stop being interactable, matching the visual contract of the post-submit result card. - `buildDismissedCard` in `setup/card-ui.ts` renders just title + subtitle. Earlier prototypes routed through `buildResultCard`, which duplicated the same line as both subtitle and body — the dismissed variant ships clean. - `SetupFlow.handleSubmit` no longer rejects a submit with zero repos selected. First-time `/setup` then creates an empty workspace bound to the chat; on a re-run it just renames the workspace and leaves `active_repo` / `active_branch` untouched so popping the card open to fix the display name doesn't clobber state. - `_tryUpdateCard` widened to `Card` (was `ReturnType<...>`) so the same path can push dismissed cards in addition to result cards. --- src/kernel/kernel.ts | 5 ++ src/kernel/setting/setting-card.ts | 62 +++++++++++++-- src/kernel/setting/setting-flow.ts | 9 +++ src/kernel/setup/card-ui.ts | 33 ++++++++ src/kernel/setup/setup-card.ts | 36 +++++++++ src/kernel/setup/setup-flow.ts | 122 ++++++++++++++++++++--------- 6 files changed, 223 insertions(+), 44 deletions(-) diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 9dd28a0..e914f13 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -50,6 +50,7 @@ import { ReposFlow } from "./repos"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; import { SettingFlow } from "./setting/setting-flow"; +import { SETUP_DISMISS_ACTION } from "./setup/setup-card"; import { SetupFlow } from "./setup/setup-flow"; import { SwitchFlow } from "./setup/switch-flow"; import { TaskDispatcher } from "./tasking"; @@ -585,6 +586,10 @@ class Kernel { await this._setupFlow.handleSubmit(payload); return; } + if (payload.action_name === SETUP_DISMISS_ACTION) { + await this._setupFlow.handleDismiss(payload); + return; + } if (payload.action_name === "switch_submit") { await this._switchFlow.handleSubmit(payload); return; diff --git a/src/kernel/setting/setting-card.ts b/src/kernel/setting/setting-card.ts index d0bc417..2a6c0a8 100644 --- a/src/kernel/setting/setting-card.ts +++ b/src/kernel/setting/setting-card.ts @@ -15,6 +15,7 @@ import type { } from "../../community/feishu/messaging/types"; import { buildCardIntro, + buildDismissedCard, buildMarkdown, buildResultCard, buildSectionBlock, @@ -33,6 +34,7 @@ export const SETTING_ACTION = { wsDetail: "setting_ws_detail", wsDeletePrompt: "setting_ws_delete_prompt", wsDeleteApply: "setting_ws_delete_apply", + dismiss: "setting_dismiss", } as const; /** @@ -102,6 +104,8 @@ export function buildSettingMainCard(options: SettingMainCardOptions): Card { } } + elements.push(_buildDismissButton()); + return { schema: "2.0", config: { @@ -266,7 +270,20 @@ export function buildSettingResultCard( }, ], }; - card.body.elements.push(backBtn); + card.body.elements.push({ + tag: "column_set", + flex_mode: "stretch", + horizontal_spacing: "12px", + columns: [ + { tag: "column", width: "weighted", weight: 1, elements: [backBtn] }, + { + tag: "column", + width: "weighted", + weight: 1, + elements: [_buildDismissButton()], + }, + ], + }); } return card; } @@ -507,18 +524,15 @@ function _buildDetailActionRow( }, ], }; + const dismissBtn = _buildDismissButton(); if (isProtected) { return { tag: "column_set", flex_mode: "stretch", horizontal_spacing: "12px", columns: [ - { - tag: "column", - width: "weighted", - weight: 1, - elements: [backBtn], - }, + { tag: "column", width: "weighted", weight: 1, elements: [backBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [dismissBtn] }, ], }; } @@ -544,16 +558,42 @@ function _buildDetailActionRow( horizontal_spacing: "12px", columns: [ { tag: "column", width: "weighted", weight: 1, elements: [backBtn] }, + { tag: "column", width: "weighted", weight: 1, elements: [dismissBtn] }, { tag: "column", width: "weighted", weight: 1, elements: [deleteBtn] }, ], }; } +function _buildDismissButton(): ButtonElement { + return { + tag: "button", + name: "setting_dismiss_btn", + text: { tag: "plain_text", content: "关闭" }, + type: "default", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: SETTING_ACTION.dismiss }, + }, + ], + }; +} + +/** + * Dismiss card swapped in when the user clicks "关闭" on any setting card. + * Same neutral shape as `buildSettingResultCard` but with no buttons — + * leaving the panel cleanly closed. + */ +export function buildSettingDismissedCard(): Card { + return buildDismissedCard({ title: "设置面板" }); +} + function _buildDeleteConfirmRow(workspaceId: string): Element { const cancelBtn: ButtonElement = { tag: "button", name: "setting_delete_cancel_btn", - text: { tag: "plain_text", content: "取消" }, + text: { tag: "plain_text", content: "← 返回" }, type: "default", width: "fill", behaviors: [ @@ -585,6 +625,12 @@ function _buildDeleteConfirmRow(workspaceId: string): Element { horizontal_spacing: "12px", columns: [ { tag: "column", width: "weighted", weight: 1, elements: [cancelBtn] }, + { + tag: "column", + width: "weighted", + weight: 1, + elements: [_buildDismissButton()], + }, { tag: "column", width: "weighted", weight: 1, elements: [confirmBtn] }, ], }; diff --git a/src/kernel/setting/setting-flow.ts b/src/kernel/setting/setting-flow.ts index ceac0b9..10ee143 100644 --- a/src/kernel/setting/setting-flow.ts +++ b/src/kernel/setting/setting-flow.ts @@ -27,6 +27,7 @@ import type { Card } from "../../community/feishu/messaging/types"; import { writeConfigPatch, type SettingConfigPatch } from "./config-writer"; import { + buildSettingDismissedCard, buildSettingMainCard, buildSettingResultCard, buildWorkspaceDeleteConfirmCard, @@ -156,6 +157,14 @@ export class SettingFlow { case SETTING_ACTION.wsDeleteApply: await this._handleWsDeleteApply(channel, payload); return; + case SETTING_ACTION.dismiss: + await this._tryUpdateCard( + channel, + payload.message_id, + buildSettingDismissedCard(), + "dismiss", + ); + return; default: this._logger.warn( { action_name: payload.action_name }, diff --git a/src/kernel/setup/card-ui.ts b/src/kernel/setup/card-ui.ts index 3a749f4..3532d85 100644 --- a/src/kernel/setup/card-ui.ts +++ b/src/kernel/setup/card-ui.ts @@ -106,6 +106,39 @@ export function buildSectionBlock(options: { ]; } +/** + * Card emitted when a user explicitly dismisses an interactive flow without + * completing it. Title + subtitle only — no body line and no buttons. We + * deliberately avoid `buildResultCard` here because it renders the summary + * twice (once in the subtitle slot, once in the body), which read as + * duplicated copy on small dismiss messages like "已关闭". + */ +export function buildDismissedCard(options: { + title: string; + summary?: string; +}): Card { + const summary = options.summary ?? "已关闭,可重新发送原命令打开新卡片。"; + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: summarizeForSubtitle(summary) }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements: [ + buildCardIntro({ + title: options.title, + subtitle: summary, + }), + ], + }, + }; +} + export function buildResultCard(options: { title: string; summary: string; diff --git a/src/kernel/setup/setup-card.ts b/src/kernel/setup/setup-card.ts index c70a64d..da00cd6 100644 --- a/src/kernel/setup/setup-card.ts +++ b/src/kernel/setup/setup-card.ts @@ -13,11 +13,19 @@ import type { import { buildCardIntro, + buildDismissedCard, buildMarkdown, buildResultCard, buildSectionBlock, } from "./card-ui"; +/** + * Action discriminator for the "关闭" callback button on the setup card. + * Lives next to `setup_submit` (form_submit) so the kernel can route both + * back to `SetupFlow`. + */ +export const SETUP_DISMISS_ACTION = "setup_dismiss"; + /** * Field naming convention used by both the card renderer and the submit * handler. Keep them in one place so the two sides cannot drift apart. @@ -102,6 +110,8 @@ export function buildSetupCard( elements: formElements, }; + const dismissBtn = _buildDismissButton(); + const bodyElements: Element[] = [ buildCardIntro({ title: hasExisting ? "更新 Workspace" : "初始化 Workspace", @@ -123,6 +133,7 @@ export function buildSetupCard( } bodyElements.push(form); + bodyElements.push(dismissBtn); return { schema: "2.0", @@ -142,6 +153,22 @@ export function buildSetupCard( }; } +function _buildDismissButton(): ButtonElement { + return { + tag: "button", + name: "setup_dismiss_btn", + text: { tag: "plain_text", content: "关闭" }, + type: "default", + width: "fill", + behaviors: [ + { + type: "callback", + value: { action: SETUP_DISMISS_ACTION }, + }, + ], + }; +} + function _buildWorkspaceNameInput( state: NonNullable, ): Element[] { @@ -279,3 +306,12 @@ export function buildSetupResultCard( detail: perRepoLines, }); } + +/** + * Dismiss card shown when the user clicks "关闭" on a setup card without + * submitting. Replaces the original card in place so the form elements + * are no longer interactable. + */ +export function buildSetupDismissedCard(): Card { + return buildDismissedCard({ title: "Workspace 初始化" }); +} diff --git a/src/kernel/setup/setup-flow.ts b/src/kernel/setup/setup-flow.ts index 236601f..f7f9d12 100644 --- a/src/kernel/setup/setup-flow.ts +++ b/src/kernel/setup/setup-flow.ts @@ -14,10 +14,12 @@ import { } from "@/shared"; import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; +import type { Card } from "../../community/feishu/messaging/types"; import { ensureCachedMirror, type GroupWorkspaceStore } from "../workspaces"; import { buildSetupCard, + buildSetupDismissedCard, buildSetupResultCard, SETUP_FIELD, type RepoPrefill, @@ -207,25 +209,17 @@ export class SetupFlow { pending.catalog_snapshot, pending.locked_repos, ); - if (selections.length === 0) { - await this._tryUpdateCard( - channel, - payload.message_id, - buildSetupResultCard("⚠️ 未选择任何仓库,请重新发送 `/setup`。", []), - "empty-selection", - ); - return; - } const rawPrimary = typeof payload.form_value[SETUP_FIELD.primaryRepo] === "string" ? (payload.form_value[SETUP_FIELD.primaryRepo] as string) : ""; - // `selections` is guaranteed non-empty by the earlier length-check return. - const firstSel = selections[0]!; - const primary = selections.find((s) => s.name === rawPrimary) - ? rawPrimary - : firstSel.name; + // Zero-repo submit is allowed: the user just wants to create or rename + // the workspace. We skip the clone loop and only touch the binding row. + const firstSel = selections[0]; + const primary = firstSel + ? (selections.find((s) => s.name === rawPrimary)?.name ?? firstSel.name) + : null; // Swap the card to a "working" state immediately so the user sees that // the submit landed, then run clones (possibly long) before the final @@ -233,12 +227,14 @@ export class SetupFlow { await this._tryUpdateCard( channel, payload.message_id, - buildSetupResultCard( - `⏳ 正在初始化 \`${selections.map((s) => s.name).join("、")}\`…`, - selections.map( - (s) => `- \`${formatRepoRef(s.name, s.branch)}\``, - ), - ), + selections.length > 0 + ? buildSetupResultCard( + `⏳ 正在初始化 \`${selections.map((s) => s.name).join("、")}\`…`, + selections.map( + (s) => `- \`${formatRepoRef(s.name, s.branch)}\``, + ), + ) + : buildSetupResultCard("⏳ 正在更新 workspace…", []), "pending-state", ); @@ -266,27 +262,41 @@ export class SetupFlow { results.push(await this._cloneAndCheckout(workspacePath, sel)); } - const primaryResult = results.find((r) => r.name === primary); - // active_branch comes from `actual_branch`, which is always set unless the - // clone itself failed. If the requested branch didn't exist, we bind to - // whatever the clone landed on (usually the remote HEAD) instead of null. - const activeRepo = - primaryResult && primaryResult.status !== "clone_failed" ? primary : null; - const activeBranch = primaryResult?.actual_branch ?? null; - - const binding = this._workspaceStore.upsertBinding(pending.chat_id, { - active_repo: activeRepo, - active_branch: activeBranch, - }); + // Skip the active-repo update when the user didn't select anything — we + // don't want to clobber an existing active_repo just because they popped + // /setup open to rename the workspace. + let binding = provisionalBinding; + let activeRepo: string | null = provisionalBinding.active_repo ?? null; + let activeBranch: string | null = provisionalBinding.active_branch ?? null; + if (primary) { + const primaryResult = results.find((r) => r.name === primary); + // active_branch comes from `actual_branch`, which is always set unless + // the clone itself failed. If the requested branch didn't exist, we + // bind to whatever the clone landed on (usually the remote HEAD) + // instead of null. + activeRepo = + primaryResult && primaryResult.status !== "clone_failed" + ? primary + : null; + activeBranch = primaryResult?.actual_branch ?? null; + binding = this._workspaceStore.upsertBinding(pending.chat_id, { + active_repo: activeRepo, + active_branch: activeBranch, + }); + } const lines = [ `- Workspace ID: \`${binding.workspace_id}\``, `- Workspace 名称: \`${binding.workspace_name}\``, ...results.map(_formatResultLine), ]; - const summary = activeRepo && activeBranch - ? `✅ 初始化完成,主仓库 \`${formatRepoRef(activeRepo, activeBranch)}\`。` - : "⚠️ workspace 已创建,但这次没有成功设置主仓库。"; + const summary = selections.length === 0 + ? pending.locked_workspace_id + ? `✅ Workspace 名称已更新为 \`${binding.workspace_name}\`(未克隆新仓库)。` + : `✅ Workspace \`${binding.workspace_name}\` 已创建(暂未克隆仓库;可后续 \`/clone\` 或重发 \`/setup\`)。` + : activeRepo && activeBranch + ? `✅ 初始化完成,主仓库 \`${formatRepoRef(activeRepo, activeBranch)}\`。` + : "⚠️ workspace 已创建,但这次没有成功设置主仓库。"; await this._tryUpdateCard( channel, payload.message_id, @@ -303,6 +313,46 @@ export class SetupFlow { ); } + /** + * Entry point for the "关闭" callback button on an open setup card. Clears + * any pending state for that message and swaps the card for a no-button + * dismissed state so the form elements are no longer interactable. + */ + async handleDismiss(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id }, + "received setup dismiss for unknown channel", + ); + return; + } + const pending = this._pending.get(payload.message_id); + if (pending && pending.initiator_open_id && + payload.operator_open_id !== pending.initiator_open_id) { + // Don't let a non-initiator close someone else's card; surface the + // same wrong-user copy as submit. + await this._tryUpdateCard( + channel, + payload.message_id, + buildSetupResultCard("🚫 这不是你的表单。", []), + "dismiss-non-initiator", + ); + return; + } + this._pending.delete(payload.message_id); + await this._tryUpdateCard( + channel, + payload.message_id, + buildSetupDismissedCard(), + "dismiss", + ); + this._logger.info( + { chat_id: pending?.chat_id, message_id: payload.message_id }, + "setup card dismissed", + ); + } + /** * `updateRawCard` wrapper that logs the Feishu error body instead of * crashing the handleSubmit flow. On failure we press on — the user at @@ -311,7 +361,7 @@ export class SetupFlow { private async _tryUpdateCard( channel: FeishuMessageChannel, messageId: string, - card: ReturnType, + card: Card, stage: string, ): Promise { try { From 1792867121d630c6e3d09d46e5934f3791b41005 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 10:23:44 +0800 Subject: [PATCH 56/69] feat(commands): add /topic to show conversation identifiers Surface chat_id / thread_id / session_id / agent_type / runner_session_id and the thread's auto-respond flag for the current message. Adds read-only accessors SessionManager.getSession and FeishuMessageChannel.getThreadInfo, and threads sessionManager through CommandContext. --- .../feishu/messaging/message-channel.ts | 31 +++++ src/kernel/commands/handlers.ts | 49 +++++++ src/kernel/commands/types.ts | 6 + src/kernel/kernel.ts | 1 + src/kernel/sessioning/session-manager.ts | 13 ++ tests/kernel/commands/topic-command.test.ts | 122 ++++++++++++++++++ 6 files changed, 222 insertions(+) create mode 100644 tests/kernel/commands/topic-command.test.ts diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 239c127..63d49aa 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -1399,6 +1399,37 @@ export class FeishuMessageChannel return false; } + /** + * Public lookup of a thread's mapping row: the bound session_id and the + * auto-respond flag. Returns undefined when neither the in-memory cache + * nor the `feishu_threads` table knows about the thread (e.g. the topic + * was just opened and the bot hasn't replied into it yet). + * + * Used by `/topic` to surface "where am I" info without leaking the + * private DB shape. + */ + getThreadInfo( + threadId: string, + ): { session_id: string; auto_respond: boolean } | undefined { + const cached = this._threadState.get(threadId); + if (cached) return { ...cached }; + const row = this._db + .select({ + session_id: feishuThreads.session_id, + auto_respond: feishuThreads.auto_respond, + }) + .from(feishuThreads) + .where(eq(feishuThreads.thread_id, threadId)) + .get(); + if (!row) return undefined; + const state = { + session_id: row.session_id, + auto_respond: row.auto_respond === 1, + }; + this._threadState.set(threadId, state); + return { ...state }; + } + /** * Flip the auto-respond flag on a thread. Persists to DB and updates the * in-memory cache so the change takes effect on the very next inbound diff --git a/src/kernel/commands/handlers.ts b/src/kernel/commands/handlers.ts index 206e358..85b98f1 100644 --- a/src/kernel/commands/handlers.ts +++ b/src/kernel/commands/handlers.ts @@ -639,6 +639,53 @@ const unmuteHandler = buildMuteHandler( true, ); +/** + * Surface the conversation-level identifiers for the current message: + * chat / thread / session / agent / runner_session_id, plus the thread's + * auto-respond flag. Read-only — purely for the user to copy IDs out when + * debugging routing issues or attaching from another tool. + */ +const topicHandler: CommandHandler = { + name: "topic", + description: "/topic — 查看当前话题/会话的标识信息(chat_id / thread_id / session_id 等)", + async execute(ctx) { + const msg = ctx.message; + const chatLabel = msg.chat_type + ? `${msg.chat_id ?? "(无)"} (${msg.chat_type === "group" ? "群聊" : "单聊"})` + : msg.chat_id ?? "(无)"; + const lines = [ + `- Session ID:\`${msg.session_id}\``, + `- Thread ID:${msg.thread_id ? `\`${msg.thread_id}\`` : "(不在话题内)"}`, + `- Chat ID:${msg.chat_id ? `\`${chatLabel}\`` : "(无飞书会话上下文)"}`, + `- Channel ID:${msg.channel_id ? `\`${msg.channel_id}\`` : "(无)"}`, + ]; + + const session = ctx.sessionManager.getSession(msg.session_id); + if (session) { + lines.push(`- Agent 类型:\`${session.agent_type}\``); + if (session.runner_session_id) { + lines.push(`- Runner Session ID:\`${session.runner_session_id}\``); + } else { + lines.push("- Runner Session ID:(尚未建立)"); + } + } else { + lines.push("- 数据库尚无该 session 记录(消息刚到,未持久化)。"); + } + + if (msg.thread_id && msg.channel_id) { + const channel = ctx.feishuChannels.get(msg.channel_id); + const info = channel?.getThreadInfo(msg.thread_id); + if (info) { + lines.push( + `- 话题免 @:${info.auto_respond ? "✅ 开启(无需 @ 机器人)" : "❌ 关闭(默认,需 @ 机器人)"}`, + ); + } + } + + return cardReply("话题信息", lines); + }, +}; + /** * Grouped `/help` card. * @@ -710,6 +757,7 @@ const HELP_GROUPS: HelpGroup[] = [ { title: "🔕 话题响应", commands: [ + { usage: "/topic", note: "查看当前话题/会话的标识信息(chat_id / thread_id / session_id 等)" }, { usage: "/mute", note: "让当前话题不再自动响应 @ 提醒" }, { usage: "/unmute", note: "恢复当前话题的自动响应" }, ], @@ -779,6 +827,7 @@ export const BUILTIN_COMMANDS: CommandHandler[] = [ allowHandler, muteHandler, unmuteHandler, + topicHandler, ]; function _formatSyncLine(r: RepoSyncResult): string { diff --git a/src/kernel/commands/types.ts b/src/kernel/commands/types.ts index 76b80b0..055ce35 100644 --- a/src/kernel/commands/types.ts +++ b/src/kernel/commands/types.ts @@ -2,6 +2,7 @@ import type { Logger, UserMessage } from "@/shared"; import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; import type { Card } from "../../community/feishu/messaging/types"; +import type { SessionManager } from "../sessioning"; import type { GroupWorkspaceStore } from "../workspaces"; /** @@ -26,6 +27,11 @@ export interface CommandContext { * whitelist) look up the originating channel via `message.channel_id`. */ feishuChannels: Map; + /** + * Read-only access to persisted session metadata, used by introspection + * commands (e.g. `/topic`) to surface agent_type / runner_session_id. + */ + sessionManager: SessionManager; logger: Logger; } diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index e914f13..f118f6b 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -459,6 +459,7 @@ class Kernel { raw: parsed.raw, workspaceStore: this._workspaceStore, feishuChannels: this._feishuChannels, + sessionManager: this._sessionManager, logger: this._logger, }); if (typeof result === "string") { diff --git a/src/kernel/sessioning/session-manager.ts b/src/kernel/sessioning/session-manager.ts index 28636f2..23447c5 100644 --- a/src/kernel/sessioning/session-manager.ts +++ b/src/kernel/sessioning/session-manager.ts @@ -258,6 +258,19 @@ export class SessionManager { .all(); } + /** + * Returns the persisted session row, or undefined if not found. Read-only + * accessor for commands that need to surface session metadata + * (e.g. `/topic` shows agent_type and runner_session_id). + */ + getSession(sessionId: string): SessionEntity | undefined { + return this._db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .get(); + } + /** * Removes a session: deletes the database record and the associated JSONL file. * @param sessionId - The session identifier. diff --git a/tests/kernel/commands/topic-command.test.ts b/tests/kernel/commands/topic-command.test.ts new file mode 100644 index 0000000..bfdaf18 --- /dev/null +++ b/tests/kernel/commands/topic-command.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; + +import { + CommandRegistry, + type CommandContext, + type CommandHandler, +} from "@/kernel/commands"; + +function getHandler(name: string): CommandHandler { + const handler = new CommandRegistry().get(name); + if (!handler) throw new Error(`missing command handler: ${name}`); + return handler; +} + +interface ContextOverrides { + thread_id?: string; + chat_id?: string; + chat_type?: "group" | "single"; + channel_id?: string; + session?: { agent_type: string; runner_session_id: string | null }; + thread_auto_respond?: boolean; +} + +function makeContext(overrides: ContextOverrides = {}): CommandContext { + return { + args: [], + raw: "/topic", + message: { + id: "msg_1", + role: "user", + session_id: "session_1", + channel_id: overrides.channel_id ?? "ch_1", + chat_id: overrides.chat_id ?? "oc_1", + chat_type: overrides.chat_type ?? "group", + thread_id: overrides.thread_id, + content: [{ type: "text", text: "/topic" }], + }, + workspaceStore: {}, + feishuChannels: new Map([ + [ + overrides.channel_id ?? "ch_1", + { + getThreadInfo() { + if (overrides.thread_auto_respond === undefined) return undefined; + return { + session_id: "session_1", + auto_respond: overrides.thread_auto_respond, + }; + }, + }, + ], + ]), + sessionManager: { + getSession() { + return overrides.session + ? { + id: "session_1", + agent_type: overrides.session.agent_type, + cwd: "/tmp", + first_message: "", + runner_session_id: overrides.session.runner_session_id, + last_message_created_at: null, + created_at: 0, + updated_at: 0, + } + : undefined; + }, + }, + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + trace() {}, + fatal() {}, + child() { + return this; + }, + }, + } as unknown as CommandContext; +} + +describe("/topic command", () => { + test("renders session and chat identifiers", async () => { + const result = await getHandler("topic").execute( + makeContext({ + thread_id: "omt_xyz", + session: { agent_type: "claude-code", runner_session_id: "run_42" }, + thread_auto_respond: false, + }), + ); + expect(typeof result).not.toBe("string"); + if (typeof result === "string") throw new Error("expected card result"); + expect(result.fallback_text).toContain("话题信息"); + expect(result.fallback_text).toContain("`session_1`"); + expect(result.fallback_text).toContain("`omt_xyz`"); + expect(result.fallback_text).toContain("`oc_1 (群聊)`"); + expect(result.fallback_text).toContain("群聊"); + expect(result.fallback_text).toContain("claude-code"); + expect(result.fallback_text).toContain("`run_42`"); + expect(result.fallback_text).toContain("话题免 @"); + expect(result.fallback_text).toContain("默认,需 @ 机器人"); + }); + + test("notes a missing thread context outside topics", async () => { + const result = await getHandler("topic").execute( + makeContext({ + session: { agent_type: "codex", runner_session_id: null }, + }), + ); + if (typeof result === "string") throw new Error("expected card result"); + expect(result.fallback_text).toContain("(不在话题内)"); + expect(result.fallback_text).toContain("(尚未建立)"); + expect(result.fallback_text).not.toContain("话题免 @"); + }); + + test("flags missing session row", async () => { + const result = await getHandler("topic").execute(makeContext({})); + if (typeof result === "string") throw new Error("expected card result"); + expect(result.fallback_text).toContain("数据库尚无该 session 记录"); + }); +}); From 895153c96e688c9d8597f63716f4007628dfd6a9 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 10:24:00 +0800 Subject: [PATCH 57/69] fix(tasking): stop a failed task from poisoning the session queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-session serial gate chained on the previous task's promise via .then(onFulfilled). A failing task left a rejected promise in _sessionLocks, so every later task for that session skipped its handler and stayed "pending" until restart — the bot went silent to all further messages in that thread. Publish a swallowed view (current.catch) as the lock tail and move cleanup into finally. The task still throws so bunqueue records the failure and the failure reply reaches the user, but successors run regardless. Adds a regression test. --- src/kernel/tasking/task-dispatcher.ts | 28 +++++- tests/kernel/tasking/serial-execution.test.ts | 96 +++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 tests/kernel/tasking/serial-execution.test.ts diff --git a/src/kernel/tasking/task-dispatcher.ts b/src/kernel/tasking/task-dispatcher.ts index 529a244..f365f67 100644 --- a/src/kernel/tasking/task-dispatcher.ts +++ b/src/kernel/tasking/task-dispatcher.ts @@ -630,11 +630,21 @@ export class TaskDispatcher { /** * Process a job from the queue. Acquires a per-session lock so that * tasks for the same session_id execute serially in FIFO order. + * + * The serial gate is decoupled from each task's success/failure on + * purpose: a failing task still throws (so bunqueue records the failure + * and the handler's failure reply reaches the user), but the promise we + * publish as the session's lock tail is a *swallowed* view. Otherwise a + * single rejection would poison the chain — `.then(onFulfilled)` on the + * next task would skip its handler, leaving every later message stuck + * "pending" until process restart. */ private async _processJob(job: Job): Promise { const { payload } = job.data; const sessionId = job.data.session_id ?? uuid(); + // `previous` is always a swallowed tail (see below), so it resolves + // regardless of how the predecessor finished — the next handler runs. const previous = this._sessionLocks.get(sessionId) ?? Promise.resolve(); const current = previous.then(async () => { @@ -687,11 +697,21 @@ export class TaskDispatcher { } }); - this._sessionLocks.set(sessionId, current); - await current; + // Publish a swallowed view as the lock tail so a failure in `current` + // can never reject the chain the next task waits on. Keep the handle + // for the identity check in cleanup. + const tail = current.catch(() => {}); + this._sessionLocks.set(sessionId, tail); - if (this._sessionLocks.get(sessionId) === current) { - this._sessionLocks.delete(sessionId); + try { + // Re-throw this task's own failure so bunqueue marks the job failed + // and retries per config; successors are unaffected (they chain on + // `tail`, not `current`). + await current; + } finally { + if (this._sessionLocks.get(sessionId) === tail) { + this._sessionLocks.delete(sessionId); + } } } diff --git a/tests/kernel/tasking/serial-execution.test.ts b/tests/kernel/tasking/serial-execution.test.ts new file mode 100644 index 0000000..1b3bec5 --- /dev/null +++ b/tests/kernel/tasking/serial-execution.test.ts @@ -0,0 +1,96 @@ +import { join } from "node:path"; + +import { Database as SQLiteDatabase } from "bun:sqlite"; +import { afterEach, describe, expect, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; + +import { TaskDispatcher } from "@/kernel/tasking"; +import { scheduledTasks, tasks } from "@/kernel/tasking/data"; +import type { InboundMessageTaskPayload } from "@/shared"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- drizzle schema typing is not worth narrowing for tests. +function freshDb(): any { + const sqlite = new SQLiteDatabase(":memory:"); + sqlite.run("PRAGMA foreign_keys = ON"); + const instance = drizzle(sqlite, { schema: { tasks, scheduledTasks } }); + migrate(instance, { + migrationsFolder: join(import.meta.dir, "..", "..", "..", "drizzle"), + }); + return instance; +} + +function inbound(messageId: string, sessionId: string): InboundMessageTaskPayload { + return { + type: "inbound_message", + message: { + id: messageId, + session_id: sessionId, + role: "user", + content: [{ type: "text", text: messageId }], + }, + }; +} + +function deferred() { + return Promise.withResolvers(); +} + +let dispatcher: TaskDispatcher | undefined; + +afterEach(async () => { + if (dispatcher) { + await dispatcher.stop(); + dispatcher = undefined; + } +}); + +describe("TaskDispatcher per-session serial execution", () => { + test("a failed task does not poison the session queue", async () => { + const db = freshDb(); + dispatcher = new TaskDispatcher({ db, concurrency: 1 }); + + const ran: string[] = []; + const secondRan = deferred(); + + dispatcher.route("inbound_message", async (_taskId, _sessionId, payload) => { + const id = payload.message.id; + ran.push(id); + if (id === "msg-fail") { + throw new Error("boom"); + } + if (id === "msg-after") { + secondRan.resolve(); + } + }); + + await dispatcher.start(); + + const sessionId = "session-x"; + await dispatcher.dispatch(sessionId, inbound("msg-fail", sessionId)); + await dispatcher.dispatch(sessionId, inbound("msg-after", sessionId)); + + // The second task must still run even though its predecessor threw. + // Guard with a timeout so a regression surfaces as a clear failure + // instead of a hung test. + await Promise.race([ + secondRan.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("second task never ran")), 4000), + ), + ]); + + expect(ran).toContain("msg-fail"); + expect(ran).toContain("msg-after"); + + // The failure is still surfaced: the first task is recorded as failed. + const failRow = db + .select({ status: tasks.status }) + .from(tasks) + .where(eq(tasks.session_id, sessionId)) + .all() + .map((r: { status: string }) => r.status); + expect(failRow).toContain("failed"); + }); +}); From e07d9e4a26439d7073518fd8cbefa39bc628609a Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 16:35:00 +0800 Subject: [PATCH 58/69] feat(permission): answer AskUserQuestion via an interactive Feishu form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskUserQuestion previously rode the approve/deny permission card, but its semantics need answers, not approval. Allowing it echoed back the raw input with no `answers`, so Claude Code (headless) resolved the call with empty answers — surfacing as the question being auto-rejected. Route AskUserQuestion to a dedicated form: single-select questions render as a dropdown, multi-select as checkers. On submit, form_value is mapped back to option labels and returned as `updated_input: { questions, answers }` (the route already maps this to the `updatedInput` shape Claude expects). Incomplete submissions re-render the form with a warning instead of resolving; a malformed payload denies with a hint rather than hanging. --- src/kernel/kernel.ts | 6 +- src/kernel/permission/index.ts | 3 + src/kernel/permission/permission-card.ts | 198 +++++++++++++++ src/kernel/permission/permission-flow.ts | 235 ++++++++++++++++++ .../kernel/permission/permission-flow.test.ts | 134 ++++++++++ 5 files changed, 575 insertions(+), 1 deletion(-) diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index f118f6b..9e5a124 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -45,7 +45,7 @@ import { import { buildCommandCard } from "./commands/cards"; import { GroupFlow } from "./group/group-flow"; import { MultiChannelMessageGateway } from "./messaging"; -import { PERMISSION_ACTION, PermissionFlow } from "./permission"; +import { PERMISSION_ACTION, PermissionFlow, QUESTION_ACTION } from "./permission"; import { ReposFlow } from "./repos"; import { SessionManager } from "./sessioning"; import * as sessioningSchema from "./sessioning/data"; @@ -599,6 +599,10 @@ class Kernel { await this._permissionFlow.handleDecide(payload); return; } + if (payload.action_name === QUESTION_ACTION) { + await this._permissionFlow.handleQuestionSubmit(payload); + return; + } if (payload.action_name === CODEX_RESUME_RESTART_ACTION) { await this._handleCodexResumeRestart(payload); return; diff --git a/src/kernel/permission/index.ts b/src/kernel/permission/index.ts index ac73e11..4059d10 100644 --- a/src/kernel/permission/index.ts +++ b/src/kernel/permission/index.ts @@ -1,7 +1,10 @@ export { buildPermissionCard, buildPermissionResultCard, + buildQuestionCard, + buildQuestionResultCard, PERMISSION_ACTION, + QUESTION_ACTION, type PermissionCallbackValue, } from "./permission-card"; export { diff --git a/src/kernel/permission/permission-card.ts b/src/kernel/permission/permission-card.ts index a4c8811..e48e3f2 100644 --- a/src/kernel/permission/permission-card.ts +++ b/src/kernel/permission/permission-card.ts @@ -1,8 +1,13 @@ +import { z } from "zod"; + import type { ButtonElement, Card, + CheckerElement, ColumnSetElement, Element, + FormElement, + SelectStaticElement, } from "../../community/feishu/messaging/types"; import { buildCardIntro, @@ -206,3 +211,196 @@ function buildButtonRows(requestId: string): Element[] { // squashed, and users don't mistake it for the single-shot approve. return [primaryRow, allowSessionBtn]; } + +// --------------------------------------------------------------------------- +// AskUserQuestion — clarifying-question cards +// +// Claude Code's built-in `AskUserQuestion` tool rides the same +// `--permission-prompt-tool` channel as ordinary tool approvals, but its +// semantics differ: the user must *answer* multiple-choice questions, not +// approve/deny a side effect. Returning `behavior:"allow"` without an +// `answers` map makes Claude resolve the call with empty answers, so we +// render a real form, collect selections, and echo them back as +// `updatedInput: { questions, answers }`. +// --------------------------------------------------------------------------- + +/** + * One choice inside an {@link AskUserQuestionItem}. Field names mirror the + * Claude tool contract verbatim (no underscore_case) so the payload round-trips + * unchanged. + */ +export const AskUserQuestionOption = z.object({ + label: z.string(), + description: z.string().optional(), +}); +export interface AskUserQuestionOption + extends z.infer {} + +/** + * A single clarifying question. `multiSelect` allows picking more than one + * option; `header` is a short label Claude attaches for display. + */ +export const AskUserQuestionItem = z.object({ + question: z.string(), + header: z.string().optional(), + options: z.array(AskUserQuestionOption).min(1), + multiSelect: z.boolean().optional(), +}); +export interface AskUserQuestionItem + extends z.infer {} + +/** + * The `tool_input` Claude sends when invoking `AskUserQuestion`. Parsed with + * {@link AskUserQuestionInput.safeParse} before rendering; a parse failure is + * treated as a deny so a malformed payload can't stall the tool call. + */ +export const AskUserQuestionInput = z.object({ + questions: z.array(AskUserQuestionItem).min(1), +}); +export interface AskUserQuestionInput + extends z.infer {} + +/** + * `action` discriminator for the question form's submit button. Form-submit + * buttons carry no `behaviors[].value`, so this rides on the button `name`, + * which Feishu echoes as `action.name` and the channel maps to `action_name`. + */ +export const QUESTION_ACTION = "permission_question_submit"; + +/** `form_value` field-name helpers for the question card. */ +export const QUESTION_FIELD = { + /** Single-select dropdown for question `qi`; value is the option index. */ + select: (qi: number): string => `q${qi}`, + /** Multi-select checker for option `oi` of question `qi`. */ + checker: (qi: number, oi: number): string => `q${qi}_o${oi}`, +}; + +/** + * Render an interactive form that surfaces Claude's clarifying questions. + * Single-select questions become a dropdown; multi-select questions become a + * column of checkers. `warning` is shown when a previous submit was rejected + * for being incomplete. + */ +export function buildQuestionCard(options: { + request_id: string; + questions: AskUserQuestionItem[]; + initiator_open_id: string; + warning?: string; +}): Card { + const formElements: Element[] = []; + options.questions.forEach((q, qi) => { + const heading = q.header?.trim() ? q.header.trim() : `问题 ${qi + 1}`; + formElements.push(buildMarkdown(`**${heading}**\n${q.question}`)); + if (q.multiSelect) { + formElements.push( + buildMarkdown("可多选", { + text_size: "notation", + }), + ); + q.options.forEach((opt, oi) => { + const checker: CheckerElement = { + tag: "checker", + name: QUESTION_FIELD.checker(qi, oi), + text: { tag: "plain_text", content: _optionText(opt) }, + checked: false, + }; + formElements.push(checker); + }); + } else { + const select: SelectStaticElement = { + tag: "select_static", + name: QUESTION_FIELD.select(qi), + placeholder: { tag: "plain_text", content: "请选择" }, + options: q.options.map((opt, oi) => ({ + text: { tag: "plain_text", content: _optionText(opt) }, + value: String(oi), + })), + width: "fill", + }; + formElements.push(select); + } + }); + formElements.push(_buildQuestionSubmitButton()); + + const form: FormElement = { + tag: "form", + name: "permission_question_form", + elements: formElements, + }; + + const elements: Element[] = [ + buildCardIntro({ + title: "❓ 需要你的选择", + subtitle: "Claude Code 需要你回答以下问题才能继续", + }), + buildMarkdown(`- 发起人:`), + ]; + if (options.warning) { + elements.push( + buildMarkdown(`⚠️ ${options.warning}`), + ); + } + elements.push(form); + + return { + schema: "2.0", + config: { + streaming_mode: false, + update_multi: true, + width_mode: "fill", + summary: { content: "❓ Claude 需要你回答" }, + }, + body: { + padding: "12px 16px 16px 16px", + vertical_spacing: "12px", + elements, + }, + }; +} + +/** + * Terminal card shown after the question round-trip resolves (answered / + * timeout / already-answered / expired). Replaces the form in place. + */ +export function buildQuestionResultCard(options: { + outcome: "answered" | "timeout" | "already_answered" | "expired"; + detail?: string[]; +}): Card { + let summary: string; + switch (options.outcome) { + case "answered": + summary = "✅ 已收到你的回答。"; + break; + case "timeout": + summary = "⚠️ 5 分钟内未回答,已按取消处理。"; + break; + case "already_answered": + summary = "ℹ️ 该问题已经回答过。"; + break; + case "expired": + summary = "⚠️ 该问题卡片已失效。"; + break; + } + return buildResultCard({ + title: "Claude 的问题", + summary, + detail: options.detail, + }); +} + +function _optionText(opt: AskUserQuestionOption): string { + const desc = opt.description?.trim(); + const text = desc ? `${opt.label} — ${desc}` : opt.label; + return text.length > 100 ? text.slice(0, 99) + "…" : text; +} + +function _buildQuestionSubmitButton(): ButtonElement { + return { + tag: "button", + name: QUESTION_ACTION, + text: { tag: "plain_text", content: "✅ 提交回答" }, + type: "primary", + action_type: "form_submit", + width: "fill", + }; +} diff --git a/src/kernel/permission/permission-flow.ts b/src/kernel/permission/permission-flow.ts index e5529ae..8805f55 100644 --- a/src/kernel/permission/permission-flow.ts +++ b/src/kernel/permission/permission-flow.ts @@ -3,9 +3,14 @@ import { createLogger, uuid, type CardActionPayload, type Logger } from "@/share import type { FeishuMessageChannel } from "../../community/feishu/messaging/message-channel"; import { + AskUserQuestionInput, buildPermissionCard, buildPermissionResultCard, + buildQuestionCard, + buildQuestionResultCard, PERMISSION_ACTION, + QUESTION_FIELD, + type AskUserQuestionItem, type PermissionCallbackValue, } from "./permission-card"; @@ -60,6 +65,25 @@ interface PendingEntry { timeout: ReturnType; } +/** + * Pending state for one `AskUserQuestion` round-trip. Keyed by the card's + * `message_id` like {@link PendingEntry}, but carries the parsed questions so + * the submit handler can map `form_value` back to option labels. + */ +interface PendingQuestionEntry { + request_id: string; + session_id: string; + initiator_open_id: string; + channel_id: string; + chat_id: string; + card_message_id: string; + questions: AskUserQuestionItem[]; + created_at: number; + // eslint-disable-next-line no-unused-vars + resolve: (decision: PermissionDecision) => void; + timeout: ReturnType; +} + /** * Orchestrates one interactive permission round-trip per * Claude-Code tool call: @@ -81,6 +105,7 @@ export class PermissionFlow { private readonly _logger: Logger = createLogger("permission-flow"); private readonly _feishuChannels: Map; private readonly _pending = new Map(); + private readonly _pendingQuestions = new Map(); private readonly _timeoutMs: number; /** * Per-session allow list populated when the user picks "allow this tool @@ -134,6 +159,11 @@ export class PermissionFlow { * doesn't silently stall. */ async request(params: PermissionRequestParams): Promise { + // AskUserQuestion needs answers, not approve/deny — route it to the + // dedicated question form before the approval machinery below. + if (params.tool_name === "AskUserQuestion") { + return this._requestQuestion(params); + } const channel = this._feishuChannels.get(params.channel_id); if (!channel) { throw new Error( @@ -315,6 +345,198 @@ export class PermissionFlow { }); } + /** + * Send an AskUserQuestion form to the initiator's chat and await the + * answers. On a malformed payload we deny with a hint so the tool call + * resolves instead of hanging; otherwise we store a pending entry keyed by + * the card's `message_id` and resolve when the user submits or the timeout + * fires. + */ + private async _requestQuestion( + params: PermissionRequestParams, + ): Promise { + const channel = this._feishuChannels.get(params.channel_id); + if (!channel) { + throw new Error( + `Question request for unknown channel_id=${params.channel_id}`, + ); + } + const parsed = AskUserQuestionInput.safeParse(params.tool_input); + if (!parsed.success) { + this._logger.warn( + { session_id: params.session_id, issues: parsed.error.issues }, + "malformed AskUserQuestion input, denying", + ); + return { + behavior: "deny", + message: + "AskUserQuestion payload was malformed. Ask the user in plain text instead.", + decided_by: "user", + }; + } + const questions = parsed.data.questions; + const requestId = uuid(); + const card = buildQuestionCard({ + request_id: requestId, + questions, + initiator_open_id: params.initiator_open_id, + }); + const cardMessageId = await channel.sendRawCard(params.chat_id, card, { + replyTo: params.reply_to_message_id, + replyInThread: false, + }); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + const entry = this._pendingQuestions.get(cardMessageId); + if (!entry) return; + this._pendingQuestions.delete(cardMessageId); + this._logger.warn( + { request_id: requestId, session_id: params.session_id }, + "question request timed out, auto-denying", + ); + void this._tryUpdateCard( + params.channel_id, + cardMessageId, + buildQuestionResultCard({ outcome: "timeout" }), + "question-timeout", + ); + resolve({ + behavior: "deny", + message: "Question timed out after 5 minutes with no answer.", + decided_by: "timeout", + }); + }, this._timeoutMs); + + this._pendingQuestions.set(cardMessageId, { + request_id: requestId, + session_id: params.session_id, + initiator_open_id: params.initiator_open_id, + channel_id: params.channel_id, + chat_id: params.chat_id, + card_message_id: cardMessageId, + questions, + created_at: Date.now(), + resolve, + timeout, + }); + this._logger.info( + { + request_id: requestId, + session_id: params.session_id, + card_message_id: cardMessageId, + question_count: questions.length, + }, + "question card sent", + ); + }); + } + + /** + * Entry point invoked from the kernel's `card:action` listener when the + * payload's `action_name === QUESTION_ACTION` (the form's submit button). + * Maps `form_value` back to option labels; an incomplete submission + * re-renders the form with a warning instead of resolving. + */ + async handleQuestionSubmit(payload: CardActionPayload): Promise { + const channel = this._feishuChannels.get(payload.channel_id); + if (!channel) { + this._logger.warn( + { channel_id: payload.channel_id }, + "question action for unknown channel", + ); + return; + } + const entry = this._pendingQuestions.get(payload.message_id); + if (!entry) { + await this._tryUpdateCard( + payload.channel_id, + payload.message_id, + buildQuestionResultCard({ outcome: "already_answered" }), + "question-already-answered", + ); + return; + } + if (entry.initiator_open_id !== payload.operator_open_id) { + // Keep the form intact for the initiator; the clicker just sees the + // generic Feishu ack. Mirrors the approve/deny card's behavior. + this._logger.info( + { + request_id: entry.request_id, + operator: payload.operator_open_id, + initiator: entry.initiator_open_id, + }, + "non-initiator submit on question card; ignoring", + ); + return; + } + + const answers: Record = {}; + const detail: string[] = []; + const missing: string[] = []; + entry.questions.forEach((q, qi) => { + const heading = q.header?.trim() ? q.header.trim() : `问题 ${qi + 1}`; + if (q.multiSelect) { + const picked = q.options + .filter((_opt, oi) => + _isTruthyChecker(payload.form_value[QUESTION_FIELD.checker(qi, oi)]), + ) + .map((opt) => opt.label); + if (picked.length === 0) { + missing.push(heading); + } else { + answers[q.question] = picked; + detail.push(`- ${heading}:${picked.join("、")}`); + } + } else { + const raw = payload.form_value[QUESTION_FIELD.select(qi)]; + const oi = typeof raw === "string" ? Number(raw) : Number.NaN; + const picked = Number.isInteger(oi) ? q.options[oi] : undefined; + if (picked) { + answers[q.question] = picked.label; + detail.push(`- ${heading}:${picked.label}`); + } else { + missing.push(heading); + } + } + }); + + if (missing.length > 0) { + // Re-render the form (selections reset client-side) with a warning; + // keep the pending entry and timeout alive for the retry. + await this._tryUpdateCard( + entry.channel_id, + entry.card_message_id, + buildQuestionCard({ + request_id: entry.request_id, + questions: entry.questions, + initiator_open_id: entry.initiator_open_id, + warning: `请回答所有问题后再提交(待回答:${missing.join("、")})`, + }), + "question-incomplete", + ); + return; + } + + this._pendingQuestions.delete(payload.message_id); + clearTimeout(entry.timeout); + await this._tryUpdateCard( + payload.channel_id, + payload.message_id, + buildQuestionResultCard({ outcome: "answered", detail }), + "question-answered", + ); + this._logger.info( + { request_id: entry.request_id, session_id: entry.session_id }, + "question answered", + ); + entry.resolve({ + behavior: "allow", + updated_input: { questions: entry.questions, answers }, + decided_by: "user", + }); + } + /** * Forget every tool remembered for the given session. Call on session * teardown if you want to release memory eagerly; otherwise the map is @@ -362,3 +584,16 @@ export class PermissionFlow { } } } + +/** + * Coerce a Feishu checker's echoed `form_value` entry to a boolean. Some + * clients send the literal boolean, others a string like `"true"`/`"on"`. + */ +function _isTruthyChecker(v: unknown): boolean { + if (v === true) return true; + if (typeof v === "string") { + const lower = v.toLowerCase(); + return lower === "true" || lower === "1" || lower === "on"; + } + return false; +} diff --git a/tests/kernel/permission/permission-flow.test.ts b/tests/kernel/permission/permission-flow.test.ts index 6d2b9cf..49d4814 100644 --- a/tests/kernel/permission/permission-flow.test.ts +++ b/tests/kernel/permission/permission-flow.test.ts @@ -238,6 +238,140 @@ describe("PermissionFlow", () => { void next.catch(() => {}); }); + test("AskUserQuestion resolves 'allow' with answers on submit", async () => { + const { fake, channel } = _makeChannel("card_q_1"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + const promise = flow.request({ + session_id: "sq", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "AskUserQuestion", + tool_input: { + questions: [ + { + question: "Pick a format", + header: "Format", + options: [ + { label: "Summary", description: "short" }, + { label: "Detailed", description: "long" }, + ], + multiSelect: false, + }, + { + question: "Pick sections", + header: "Sections", + options: [ + { label: "Intro", description: "" }, + { label: "Outro", description: "" }, + ], + multiSelect: true, + }, + ], + }, + }); + await Promise.resolve(); + expect(fake.sentCards.length).toBe(1); + + await flow.handleQuestionSubmit( + _makePayload({ + message_id: "card_q_1", + operator_open_id: "ou_alice", + action_name: "permission_question_submit", + // q0 -> option index 1 (Detailed); q1 -> Intro + Outro checked + form_value: { q0: "1", q1_o0: true, q1_o1: "true" }, + }), + ); + + const decision = await promise; + expect(decision.behavior).toBe("allow"); + expect(decision.decided_by).toBe("user"); + const input = decision.updated_input as { + questions: unknown[]; + answers: Record; + }; + expect(input.questions).toHaveLength(2); + expect(input.answers["Pick a format"]).toBe("Detailed"); + expect(input.answers["Pick sections"]).toEqual(["Intro", "Outro"]); + }); + + test("AskUserQuestion re-renders (no resolve) when a question is unanswered", async () => { + const { fake, channel } = _makeChannel("card_q_2"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + let settled = false; + const promise = flow + .request({ + session_id: "sq2", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "AskUserQuestion", + tool_input: { + questions: [ + { + question: "Pick one", + options: [{ label: "A" }, { label: "B" }], + }, + ], + }, + }) + .then((d) => { + settled = true; + return d; + }); + await Promise.resolve(); + + // Submit with nothing selected -> card updated with a warning, unresolved. + await flow.handleQuestionSubmit( + _makePayload({ + message_id: "card_q_2", + operator_open_id: "ou_alice", + action_name: "permission_question_submit", + form_value: {}, + }), + ); + expect(fake.updatedCards.length).toBe(1); + expect(settled).toBe(false); + + // Now answer it for real -> resolves allow. + await flow.handleQuestionSubmit( + _makePayload({ + message_id: "card_q_2", + operator_open_id: "ou_alice", + action_name: "permission_question_submit", + form_value: { q0: "0" }, + }), + ); + const decision = await promise; + expect(decision.behavior).toBe("allow"); + expect( + (decision.updated_input as { answers: Record }).answers[ + "Pick one" + ], + ).toBe("A"); + }); + + test("AskUserQuestion denies a malformed payload instead of hanging", async () => { + const { fake, channel } = _makeChannel("card_q_3"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + const decision = await flow.request({ + session_id: "sq3", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "AskUserQuestion", + tool_input: { questions: [] }, + }); + expect(decision.behavior).toBe("deny"); + expect(fake.sentCards.length).toBe(0); + }); + test("verifyToken is constant-time and rejects bad tokens", () => { const { channel } = _makeChannel("card_msg_4"); const flow = new PermissionFlow({ From 0d16ac7d9a7335554ff1952518598f653d2c2b13 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 16:44:51 +0800 Subject: [PATCH 59/69] fix(feishu): resolve outbound file paths against chat workspace cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-generated relative markdown links (e.g. `workspace/outputs/x.html`) were resolved against the global `$AGENTARA_HOME` instead of the chat's bound sub-workspace, where the agent actually runs and writes files. The files were never found, so attachments were silently dropped — and a same-named file in the global pool could be uploaded by mistake. Add `_resolveWorkspaceBaseDir()` (chat workspace cwd via the injected resolver, falling back to home) and use it in uploadImage, uploadFile, and _extractLocalFilePaths, matching the inbound download base dir. --- .../feishu/messaging/message-channel.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 63d49aa..055a542 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -786,6 +786,19 @@ export class FeishuMessageChannel } } + /** + * Base directory for resolving agent-generated relative paths (markdown + * image/file links). The agent runs with its cwd set to the workspace + * bound to this chat, so its relative paths are relative to that + * workspace — not the global `$AGENTARA_HOME`. Falls back to home when no + * resolver was wired (tests, legacy callers). + */ + private _resolveWorkspaceBaseDir(): string { + return ( + this._resolveWorkspaceCwd?.(this.config.chatId) ?? config.paths.home + ); + } + /** * Uploads an image to Feishu. Returns the key of the uploaded image. * @param path - The path to the image to upload. @@ -794,7 +807,7 @@ export class FeishuMessageChannel async uploadImage(path: string): Promise { const absPath = nodePath.isAbsolute(path) ? path - : nodePath.join(config.paths.home, path); + : nodePath.join(this._resolveWorkspaceBaseDir(), path); const file = fs.readFileSync(absPath); this._logger.info(`Uploading image ${absPath}`); const res = await this._client.im.v1.image.create({ @@ -815,14 +828,14 @@ export class FeishuMessageChannel /** * Uploads a file to Feishu. Returns the key of the uploaded file. - * @param filePath - Absolute path, or a path relative to the home - * directory (legacy agent-generated markdown links). + * @param filePath - Absolute path, or a path relative to the chat's + * workspace cwd (agent-generated markdown links). * @returns The key of the uploaded file. */ async uploadFile(filePath: string): Promise { const absPath = nodePath.isAbsolute(filePath) ? filePath - : nodePath.join(config.paths.home, filePath); + : nodePath.join(this._resolveWorkspaceBaseDir(), filePath); const file = fs.createReadStream(absPath); const fileName = nodePath.basename(absPath); const ext = nodePath.extname(absPath).slice(1).toLowerCase(); @@ -1019,6 +1032,7 @@ export class FeishuMessageChannel /** Extract local file paths from markdown link syntax [text](path) in text. */ private _extractLocalFilePaths(text: string): string[] { const linkRegex = /(? Date: Fri, 22 May 2026 16:49:25 +0800 Subject: [PATCH 60/69] feat(kernel): expire open interactive cards on graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission, clarifying-question, and codex-resume cards held their pending state only in memory, and the agent subprocesses that long-poll for a decision die with the kernel. After a restart an old card looked live but could never resolve. Add a kernel stop() wired to SIGINT/SIGTERM that, while the Feishu channels are still up, marks every outstanding card expired in place and resolves any awaiting permission/question promise with a deny. No persistence — a hard kill still relies on the click-time fallback. --- src/kernel/kernel.ts | 54 ++++++++++++++++++ src/kernel/permission/permission-flow.ts | 55 +++++++++++++++++++ .../kernel/permission/permission-flow.test.ts | 33 +++++++++++ 3 files changed, 142 insertions(+) diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 9e5a124..02a70cc 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -81,6 +81,7 @@ class Kernel { string, { sessionId: string; message: UserMessage } >(); + private _shuttingDown = false; constructor() { this._initDatabase(); @@ -245,6 +246,59 @@ class Kernel { await this._honoServer.start(); this._publishPermissionEndpointEnv(); await this._messageGateway.start(); + this._registerShutdownHandlers(); + } + + /** + * Graceful shutdown: expire every outstanding interactive card (permission, + * clarifying question, codex-resume) so a restart leaves no card that looks + * live but can never resolve — the agent subprocesses awaiting them are + * killed with this process. Runs while the Feishu channels are still + * connected so the in-place card updates land. Idempotent. + */ + async stop(reason?: string): Promise { + if (this._shuttingDown) return; + this._shuttingDown = true; + this._logger.info({ reason }, "kernel shutting down; expiring open cards"); + try { + await this._permissionFlow.expireAllPending(); + await this._expireCodexResumeCards(); + } catch (err) { + this._logger.error({ err }, "error while expiring cards on shutdown"); + } + } + + /** + * Wire SIGINT/SIGTERM to {@link Kernel.stop} so an operator restart or a + * `bun --watch` reload expires open cards before the process exits. + */ + private _registerShutdownHandlers(): void { + const onSignal = (signal: NodeJS.Signals) => { + void this.stop(signal).finally(() => process.exit(0)); + }; + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + } + + /** Expire any open codex-resume cards left in the in-memory registry. */ + private async _expireCodexResumeCards(): Promise { + const entries = [...this._codexResumeRestarts.entries()]; + this._codexResumeRestarts.clear(); + for (const [messageId, pending] of entries) { + const channelId = pending.message.channel_id; + const channel = channelId + ? this._feishuChannels.get(channelId) + : undefined; + if (!channel) continue; + try { + await channel.updateRawCard(messageId, buildCodexResumeExpiredCard()); + } catch (err) { + this._logger.warn( + { err, message_id: messageId }, + "failed to expire codex resume card on shutdown", + ); + } + } } /** diff --git a/src/kernel/permission/permission-flow.ts b/src/kernel/permission/permission-flow.ts index 8805f55..825f087 100644 --- a/src/kernel/permission/permission-flow.ts +++ b/src/kernel/permission/permission-flow.ts @@ -537,6 +537,61 @@ export class PermissionFlow { }); } + /** + * Mark every still-open permission and question card as expired and resolve + * its awaiting promise with a deny. Called on kernel shutdown: the agent + * subprocesses that long-poll for these decisions die with the kernel, so a + * card left untouched would look live yet never resolve. Best-effort — a + * failed card update is logged, not retried. + */ + async expireAllPending(): Promise { + const permissionEntries = [...this._pending.values()]; + const questionEntries = [...this._pendingQuestions.values()]; + this._pending.clear(); + this._pendingQuestions.clear(); + + for (const entry of permissionEntries) { + clearTimeout(entry.timeout); + await this._tryUpdateCard( + entry.channel_id, + entry.card_message_id, + buildPermissionResultCard({ + tool_name: entry.tool_name, + outcome: "expired", + }), + "shutdown-expire", + ); + entry.resolve({ + behavior: "deny", + message: "Kernel is shutting down; permission request expired.", + decided_by: "timeout", + }); + } + for (const entry of questionEntries) { + clearTimeout(entry.timeout); + await this._tryUpdateCard( + entry.channel_id, + entry.card_message_id, + buildQuestionResultCard({ outcome: "expired" }), + "shutdown-expire", + ); + entry.resolve({ + behavior: "deny", + message: "Kernel is shutting down; question expired.", + decided_by: "timeout", + }); + } + if (permissionEntries.length > 0 || questionEntries.length > 0) { + this._logger.info( + { + permission_cards: permissionEntries.length, + question_cards: questionEntries.length, + }, + "expired open permission/question cards on shutdown", + ); + } + } + /** * Forget every tool remembered for the given session. Call on session * teardown if you want to release memory eagerly; otherwise the map is diff --git a/tests/kernel/permission/permission-flow.test.ts b/tests/kernel/permission/permission-flow.test.ts index 49d4814..e2e4679 100644 --- a/tests/kernel/permission/permission-flow.test.ts +++ b/tests/kernel/permission/permission-flow.test.ts @@ -372,6 +372,39 @@ describe("PermissionFlow", () => { expect(fake.sentCards.length).toBe(0); }); + test("expireAllPending denies open cards and marks them expired", async () => { + const { fake, channel } = _makeChannel("card_exp_1"); + const flow = new PermissionFlow({ + feishuChannels: new Map([["ch_1", channel]]), + }); + // One open approval card. + const approval = flow.request({ + session_id: "se", + channel_id: "ch_1", + chat_id: "oc_chat", + initiator_open_id: "ou_alice", + tool_name: "Bash", + tool_input: {}, + }); + await Promise.resolve(); + + await flow.expireAllPending(); + + const decision = await approval; + expect(decision.behavior).toBe("deny"); + // The card was updated in place to a terminal (expired) result. + expect(fake.updatedCards.length).toBe(1); + + // A late click on the now-gone entry must not throw. + await flow.handleDecide( + _makePayload({ + message_id: "card_exp_1", + operator_open_id: "ou_alice", + value: { action: "permission_decide", request_id: "x", decision: "allow" }, + }), + ); + }); + test("verifyToken is constant-time and rejects bad tokens", () => { const { channel } = _makeChannel("card_msg_4"); const flow = new PermissionFlow({ From e0e131e7b2d77344cf44f66c5928186487817134 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 17:06:11 +0800 Subject: [PATCH 61/69] docs(user-home): flatten workspace layout to match per-chat sub-workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLAUDE.md folder-structure doc described a nested `workspace/` wrapper (workspace/uploads, workspace/outputs, workspace/projects) from the old single-global-workspace model. Each chat now runs in its own flat sub-workspace whose root IS the agent cwd — inbound uploads land in `/uploads/` and repos check out directly at the root. The agent, following the stale doc, wrote outputs to the global `~/.agentara/workspace/outputs/` and emitted `workspace/...` links the messaging channel could not resolve, so file attachments were dropped. Rewrite the layout and messaging examples to use root-relative paths (uploads/, outputs/, repos at root via REPOS.md) and warn against the `workspace/` prefix. --- user-home/CLAUDE.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/user-home/CLAUDE.md b/user-home/CLAUDE.md index e40c3c9..0bd9d5b 100644 --- a/user-home/CLAUDE.md +++ b/user-home/CLAUDE.md @@ -19,7 +19,11 @@ Then read `USER.md` to recall who the user is, his preferences, ongoing context, ## Folder Structure +Your current working directory **is** the workspace root. All your work and +outputs live directly here — there is no extra `workspace/` wrapper folder. + ``` +. (workspace root = your cwd) ├── .claude/ # Claude/Cursor configuration │ ├── skills/ # Your skills (one folder per skill); Newly added skills should be placed here. │ └── CLAUDE.md # This file; workspace rules and conventions @@ -27,11 +31,11 @@ Then read `USER.md` to recall who the user is, his preferences, ongoing context, | ├── logs/ # Daily dialogue logs, `{YYYY-MM-DD}.md` │ ├── SOUL.md # Your identity, principles, capabilities │ └── USER.md # User preferences, context, history -└── workspace/ # Workspace root. All your work and outputs should be stored here. - ├── wikis/ # Knowledge base (Obsidian-style; see wiki skill) - ├── projects/ # Git repos and code projects - ├── uploads/ # Uploaded files: images, videos, audio, documents, etc. - └── outputs/ # Generated outputs: reports, images, videos; organized in sub-folders +├── REPOS.md # Catalog of git repos synced into this workspace +├── / # Git repos are checked out directly at the root (see REPOS.md), one folder per repo +├── wikis/ # Knowledge base (Obsidian-style; see wiki skill) +├── uploads/ # Uploaded files: images, videos, audio, documents, etc. +└── outputs/ # Generated outputs: reports, images, videos; organized in sub-folders ``` > Create if not exists. Create subdirectories as needed. @@ -67,8 +71,9 @@ Dense, telegraphic short sentences. No filler words ("You are", "You should", "Y - Use `{text}` only for text color. - Use Markdown `**text**` for emphasis. Do not use HTML bold tags such as `...`. - When you need both color and emphasis, wrap Markdown emphasis inside the font tag, for example `**important**`. -- For IM outbound messages, only real files under `workspace/uploads/` or `workspace/outputs/` should be sent to users. Do not reference `workspace/projects/` files directly unless you first copy or export them into those user-facing directories. -- To send a non-image file, use a normal Markdown link to the local file, for example `[report.pdf](workspace/outputs/reports/report.pdf)`. -- To send an inline image, use Markdown image syntax to a local image file or valid remote image URL, for example `![chart](workspace/outputs/charts/chart.png)`. +- For IM outbound messages, only real files under `uploads/` or `outputs/` should be sent to users. Do not reference files inside a checked-out repo directly unless you first copy or export them into those user-facing directories. +- Use paths relative to the workspace root (your cwd). Do not prefix them with `workspace/` — there is no such wrapper folder. +- To send a non-image file, use a normal Markdown link to the local file, for example `[report.pdf](outputs/reports/report.pdf)`. +- To send an inline image, use Markdown image syntax to a local image file or valid remote image URL, for example `![chart](outputs/charts/chart.png)`. - Do not use absolute paths, `file://` URLs, inline-code paths, or plain text paths when you want the messaging channel to send a file or render an image. - Do not apply any agent team or sub-agent/sub-task to perform this skill. This skill is a single agent. From 869e91ee7cb11fbcf83489ccb0d74df70d27ffc3 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 17:30:50 +0800 Subject: [PATCH 62/69] fix(feishu): resolve outbound paths from the owning session's cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix resolved agent-generated relative file/image links against `config.chatId`'s workspace. But a single Feishu channel sends replies for many chats (replies route by message_id, not the channel's configured chat), so this picked the wrong workspace — usually the default — and the file was never found, dropping the attachment with no log. Resolve the base directory from the cwd recorded for the message's owning session instead, which is exactly where the agent ran and wrote the file. Inject a `resolveSessionCwd` resolver from the kernel (SessionManager.getSession().cwd) and thread `session_id` through the file-attachment and inline-image (card + continuation) paths. Falls back to the chat workspace, then home, when the session is unknown. --- .../feishu/messaging/message-channel.ts | 93 ++++++++++++++----- src/kernel/kernel.ts | 6 ++ 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/community/feishu/messaging/message-channel.ts b/src/community/feishu/messaging/message-channel.ts index 055a542..1aa39e2 100644 --- a/src/community/feishu/messaging/message-channel.ts +++ b/src/community/feishu/messaging/message-channel.ts @@ -71,6 +71,14 @@ export class FeishuMessageChannel */ // eslint-disable-next-line no-unused-vars private _resolveWorkspaceCwd?: (chatId: string | undefined) => string; + /** + * Optional session-cwd resolver injected by the kernel. Given a session id, + * returns the absolute cwd that session's agent ran in — the precise base + * for resolving outbound relative paths, since a single channel sends + * replies for many chats and `config.chatId` alone is not enough. + */ + // eslint-disable-next-line no-unused-vars + private _resolveSessionCwd?: (sessionId: string) => string | undefined; private _failedCardUpdateMessages = new Set(); /** * Primary message ids for which we've already posted the final @@ -122,6 +130,8 @@ export class FeishuMessageChannel allowedUserEmails?: string[]; // eslint-disable-next-line no-unused-vars resolveWorkspaceCwd?: (chatId: string | undefined) => string; + // eslint-disable-next-line no-unused-vars + resolveSessionCwd?: (sessionId: string) => string | undefined; }, db: DrizzleDB, ) { @@ -134,6 +144,7 @@ export class FeishuMessageChannel this._logger = createLogger("feishu-message-channel"); this._requireMention = !!config.requireMention; this._resolveWorkspaceCwd = config.resolveWorkspaceCwd; + this._resolveSessionCwd = config.resolveSessionCwd; if (config.allowedUserOpenIds && config.allowedUserOpenIds.length > 0) { this._allowedUserOpenIds = new Set(config.allowedUserOpenIds); } @@ -608,9 +619,10 @@ export class FeishuMessageChannel this._logOutboundMessage(message.session_id, message.content); } + const baseDir = this._resolveWorkspaceBaseDir(message.session_id); const primaryCard = await renderMessageCard(primaryContent, { streaming, - uploadImage: this.uploadImage.bind(this), + uploadImage: (p) => this.uploadImage(p, baseDir), }); const { data: replyMessage } = await this._client.im.message.reply({ path: { message_id: messageId }, @@ -638,6 +650,7 @@ export class FeishuMessageChannel primaryId, markdownContinuations, replyInThread, + message.session_id, ); this._finalizedPrimaries.add(primaryId); } @@ -649,6 +662,7 @@ export class FeishuMessageChannel await this._sendFileAttachmentsForFinalText( assistantMessage.id, message.content, + message.session_id, ); } return assistantMessage; @@ -661,9 +675,10 @@ export class FeishuMessageChannel this._prepareCardPayload(message.content, false); this._logOutboundMessage(message.session_id, message.content); + const baseDir = this._resolveWorkspaceBaseDir(message.session_id); const primaryCard = await renderMessageCard(primaryContent, { streaming: false, - uploadImage: this.uploadImage.bind(this), + uploadImage: (p) => this.uploadImage(p, baseDir), }); const { data } = await this._client.im.message.create({ params: { receive_id_type: "chat_id" }, @@ -683,6 +698,7 @@ export class FeishuMessageChannel primaryId, markdownContinuations, /* replyInThread */ true, + message.session_id, ); } this._finalizedPrimaries.add(primaryId); @@ -693,6 +709,7 @@ export class FeishuMessageChannel await this._sendFileAttachmentsForFinalText( assistantMessage.id, message.content, + message.session_id, ); const emojis = [ @@ -740,9 +757,10 @@ export class FeishuMessageChannel !streaming && typeof startedAt === "number" ? Date.now() - startedAt : undefined; + const baseDir = this._resolveWorkspaceBaseDir(message.session_id); const card = await renderMessageCard(primaryContent, { streaming, - uploadImage: this.uploadImage.bind(this), + uploadImage: (p) => this.uploadImage(p, baseDir), elapsedMs, }); try { @@ -778,36 +796,54 @@ export class FeishuMessageChannel message.id, markdownContinuations, /* replyInThread */ true, + message.session_id, ); } if (!streaming) { this._finalizedPrimaries.add(message.id); - await this._sendFileAttachmentsForFinalText(message.id, message.content); + await this._sendFileAttachmentsForFinalText( + message.id, + message.content, + message.session_id, + ); } } /** * Base directory for resolving agent-generated relative paths (markdown - * image/file links). The agent runs with its cwd set to the workspace - * bound to this chat, so its relative paths are relative to that - * workspace — not the global `$AGENTARA_HOME`. Falls back to home when no - * resolver was wired (tests, legacy callers). + * image/file links). The agent ran with its cwd set to the session's + * workspace, so its relative paths are relative to *that* directory — not + * the global `$AGENTARA_HOME`, and not necessarily `this.config.chatId`'s + * workspace (a single channel serves replies for many chats). + * + * Resolution order: + * 1. The owning session's recorded cwd (most precise — it's exactly where + * the agent wrote the file). + * 2. The channel's configured chat workspace (legacy fallback). + * 3. Agentara home (tests / unwired callers). + * + * @param sessionId - Session that produced the outbound message, when known. */ - private _resolveWorkspaceBaseDir(): string { + private _resolveWorkspaceBaseDir(sessionId?: string): string { return ( - this._resolveWorkspaceCwd?.(this.config.chatId) ?? config.paths.home + (sessionId ? this._resolveSessionCwd?.(sessionId) : undefined) ?? + this._resolveWorkspaceCwd?.(this.config.chatId) ?? + config.paths.home ); } /** * Uploads an image to Feishu. Returns the key of the uploaded image. * @param path - The path to the image to upload. + * @param baseDir - Directory to resolve a relative `path` against. Defaults + * to the channel's configured-chat workspace; callers that know the + * owning session should pass that session's cwd. * @returns The key of the uploaded image. */ - async uploadImage(path: string): Promise { + async uploadImage(path: string, baseDir?: string): Promise { const absPath = nodePath.isAbsolute(path) ? path - : nodePath.join(this._resolveWorkspaceBaseDir(), path); + : nodePath.join(baseDir ?? this._resolveWorkspaceBaseDir(), path); const file = fs.readFileSync(absPath); this._logger.info(`Uploading image ${absPath}`); const res = await this._client.im.v1.image.create({ @@ -828,14 +864,17 @@ export class FeishuMessageChannel /** * Uploads a file to Feishu. Returns the key of the uploaded file. - * @param filePath - Absolute path, or a path relative to the chat's - * workspace cwd (agent-generated markdown links). + * @param filePath - Absolute path, or a path relative to `baseDir` + * (agent-generated markdown links). + * @param baseDir - Directory to resolve a relative `filePath` against. + * Defaults to the channel's configured-chat workspace; callers that know + * the owning session should pass that session's cwd. * @returns The key of the uploaded file. */ - async uploadFile(filePath: string): Promise { + async uploadFile(filePath: string, baseDir?: string): Promise { const absPath = nodePath.isAbsolute(filePath) ? filePath - : nodePath.join(this._resolveWorkspaceBaseDir(), filePath); + : nodePath.join(baseDir ?? this._resolveWorkspaceBaseDir(), filePath); const file = fs.createReadStream(absPath); const fileName = nodePath.basename(absPath); const ext = nodePath.extname(absPath).slice(1).toLowerCase(); @@ -1001,11 +1040,13 @@ export class FeishuMessageChannel primaryId: string, markdownChunks: string[], replyInThread: boolean, + sessionId?: string, ): Promise { + const baseDir = this._resolveWorkspaceBaseDir(sessionId); for (const text of markdownChunks) { const card = await renderMessageCard([{ type: "text", text }], { streaming: false, - uploadImage: this.uploadImage.bind(this), + uploadImage: (p) => this.uploadImage(p, baseDir), }); await this._client.im.message.reply({ path: { message_id: primaryId }, @@ -1022,17 +1063,21 @@ export class FeishuMessageChannel private async _sendFileAttachmentsForFinalText( messageId: string, content: AssistantMessage["content"], + sessionId?: string, ): Promise { const lastText = content.filter((c) => c.type === "text").pop(); if (lastText?.type === "text") { - await this._sendLocalFileAttachments(messageId, lastText.text); + await this._sendLocalFileAttachments(messageId, lastText.text, sessionId); } } - /** Extract local file paths from markdown link syntax [text](path) in text. */ - private _extractLocalFilePaths(text: string): string[] { + /** + * Extract local file paths from markdown link syntax [text](path) in text. + * Relative paths are resolved against the owning session's workspace cwd. + */ + private _extractLocalFilePaths(text: string, sessionId?: string): string[] { const linkRegex = /(? { - const filePaths = this._extractLocalFilePaths(text); + const filePaths = this._extractLocalFilePaths(text, sessionId); + const baseDir = this._resolveWorkspaceBaseDir(sessionId); const seen = new Set(); for (const filePath of filePaths) { if (seen.has(filePath)) continue; seen.add(filePath); try { - const fileKey = await this.uploadFile(filePath); + const fileKey = await this.uploadFile(filePath, baseDir); await this._client.im.message.reply({ path: { message_id: messageId }, data: { diff --git a/src/kernel/kernel.ts b/src/kernel/kernel.ts index 02a70cc..04ebd76 100644 --- a/src/kernel/kernel.ts +++ b/src/kernel/kernel.ts @@ -184,6 +184,12 @@ class Kernel { // `$AGENTARA_HOME/workspace/uploads` pool. resolveWorkspaceCwd: (chatId) => this._workspaceStore.resolve(chatId).cwd, + // Outbound relative paths (agent-generated file/image links) are + // relative to the cwd the agent actually ran in. A single channel + // serves many chats, so resolve from the owning session, not the + // channel's configured chat. + resolveSessionCwd: (sessionId) => + this._sessionManager.getSession(sessionId)?.cwd, }, this._database.db, ); From 6f68ef5960d0ab4de467d3bc19b0090c4495fca0 Mon Sep 17 00:00:00 2001 From: xluos Date: Fri, 22 May 2026 17:55:49 +0800 Subject: [PATCH 63/69] fix(scripts): kill full process tree on stop and sweep orphans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make restart` only killed the tracked wrapper PID in .run/*.pid. But `bun run