Skip to content

feat(collab): share projects and tasks with members - #783

Open
shinetata wants to merge 10 commits into
MemTensor:v1.2.8from
shinetata:feat/collab-projects-v1.2.8
Open

shinetata wants to merge 10 commits into
MemTensor:v1.2.8from
shinetata:feat/collab-projects-v1.2.8

Conversation

@shinetata

@shinetata shinetata commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

A project stays local and gains a sharing switch: turn it on and its sessions, task associations and instructions become readable by the members of the linked online project, while everything else stays on this computer.

Members and invitations

  • Online projects carry owner, admin and member roles, an invite dialog and join requests with a pending badge.
  • Approving or rejecting a request records the decision with its time, so the applicant sees a result once instead of a request that silently disappears. Results are kept for a week.
  • Member and request routes accept the 19-digit snowflake account ids the desktop uses.

Sessions on the board

  • The board lists a project's sessions from the cloud: member filter, keyword search, pagination, a reader panel that shows a session as question and reply, and per-session copy link and delete.
  • A session stays on this computer until its own "share with members" switch is on, so a project can share a few conversations instead of everything; sessions uploaded by earlier versions keep counting as shared.
  • A row opens the person's own task in its conversation page, while other members' sessions open in the read-only reader. "Continue task" starts a new task in this project that references that record, so the agent picks up where it stopped.
  • Sessions are uploaded from the local agent stores (Memmy, Claude Code, Codex, Cursor, WorkBuddy, Hermes, DeepSeek Harness, OpenCode, QwenWork, Pi), either one by one or by associating whole groups, with a 20-per-member quota and a progress strip while an upload runs.
  • Linking and unlinking other agents' tasks go through one save, keep per-session exclusions, and ask before removing a whole group.
  • A task summary stops on the most recent real question: Cursor and Codex write their task-finished notices as a turn, so the cloud skips those turns when it stores the summary, and the board keeps records from earlier uploads from showing the raw <timestamp> / <system_notification> text.

First-run guidance

  • Starting collaboration for the first time walks through the board in six steps: what the board and a shared task are, inviting members, linking tasks from other agents, sharing a task by conversation, continuing a task, and starting the first one.
  • The tour follows the onboarding tour pattern: a spotlight on the target, a bubble that follows it, and skip / back / next controls.
  • While it runs the board shows only the tour's own sample rows — real tasks, sync progress, the member tip and paging step aside — so a replay never mixes demo rows with the project's actual tasks. Completion and skipping are recorded per machine, and project settings offers a replay entry.
  • The member tip names the people who left tasks — one name, two joined, or "first and N other members" — instead of saying "members", so it reads as a note about someone else's tasks rather than about the person's own uploads.

Project instructions and knowledge

  • Project instructions have their own stores for local and shared projects, and are injected into agents on this computer: Memmy tasks through its own hook, other agents through the resume hooks, including after a context compaction.
  • Stopping sharing hands the shared background & guidelines back to the local project, so the work done on them is not lost.
  • A project can reference knowledge bases, which then join the retrieval scope of its members. memmy-project searches them on demand.

CLI and agent integration

  • memmy-session reads and searches a project's sessions; a turn can be addressed as ID#turn.
  • memmy-project is shipped as a built-in skill and searches a linked project's knowledge.
  • The session CLI and its skill are installed after a normal app launch, not only after a manual scan.

Performance

  • The board reads only the project and task lists and is memoized, so a streaming task no longer re-renders it (30 unrelated state updates: 105.6ms -> 0.3ms).
  • The board poll, the manual refresh and the post-upload reload compare before writing; the project store stays silent when a reload brings the same content; polling skips hidden windows and refreshes on return.
  • A long session renders its first turns synchronously and defers the rest (opening 40 turns: 99.8ms -> 36.2ms).
  • Switching agents in the association picker updates the selection before the list rebuilds (5.4ms -> 0.7ms for the immediate commit).

Fixes along the way

  • Read Cursor conversation headers from composerConvHeaders rows, which the newer format fills instead of the older header list.
  • Ignore the Enter that confirms an IME candidate in the project dialogs.
  • Keep the collab dialogs at their native widths, and stop the collab scrim from inheriting the default 8px blur and 32% dim.
  • Accept a sandboxed connection to Memmy instead of reporting a missing app.

Tests

Board, association picker, knowledge dialog, member dialog, project store, instruction stores, the local session reader, the first-run guide, and the CLI.

Note

The repository does not allow squash merges, so merging keeps the nine commits as they are. Each one is a self-contained step: the feature itself, its fixes, the built-in skill, per-session sharing, the first-run guide, and the summary and member-tip polish.

A project stays local and gains a sharing switch: turn it on and its sessions,
its task associations and its instructions become readable by the members of
the linked online project, while everything else stays on this computer.

Members and invitations
- Online projects carry owner, admin and member roles, an invite dialog, join
  requests with a pending badge, and a recorded approve or reject decision that
  the applicant sees once instead of a silently disappearing request.
- Member and request routes accept the 19-digit snowflake account ids the
  desktop uses.

Sessions on the board
- The board lists a project's sessions from the cloud: member filter, keyword
  search, pagination, a reader panel that shows a session as question and
  reply, and per-session copy link and delete.
- Sessions are uploaded from the local agent stores (Memmy, Claude Code, Codex,
  Cursor, WorkBuddy, Hermes, DeepSeek Harness, OpenCode, QwenWork, Pi), either
  one by one or by associating whole groups, with a 20-per-member quota and a
  progress strip while an upload runs.
- Linking and unlinking of other agents' tasks goes through one save, keeps
  per-session exclusions, and asks before removing a whole group.

Project instructions and knowledge
- Project instructions have their own stores for local and shared projects and
  are injected into agents on this computer, both for Memmy tasks and through
  the resume hooks of other agents.
- A project can reference knowledge bases, which then join the retrieval scope
  of its members.

CLI and agent integration
- memmy-session reads and searches a project's sessions, memmy-project searches
  a linked project's knowledge, and the session CLI and skill are installed
  after a normal app launch.

Performance
- The board reads only the project and task lists and is memoized, so a
  streaming task no longer re-renders it; the poll and the project store
  compare before writing and skip hidden windows; a long session renders its
  first turns synchronously and defers the rest; agent switching in the
  association picker updates the selection before the list.

Fixes along the way
- Read Cursor conversation headers from composerConvHeaders rows, ignore the
  Enter that confirms an IME candidate, keep the collab dialogs at their native
  widths, and stop the collab scrim from inheriting the default 8px blur.

Tests cover the board, the association picker, the knowledge dialog, the member
dialog, the project store, the instruction stores and the CLI.
… list

A local project has no toolbar between its header and its task list, and the
stream list resets its margin, so the first row sat at the header's edge and a
hovered row's raised outline touched the project name. Give the local list the
same top gap the shared list already gets.
@Memtensor-AI

Memtensor-AI commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #783
Task: 2ee782417f520b5c
Base: v1.2.8
Head: feat/collab-projects-v1.2.8

🔍 OpenCodeReview found 195 issue(s) in this PR.

⚠️ Review warnings (49)

  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • completed primary review; remaining group budget is insufficient for another measured pass and finalization
  • semantic groups split by file count and complete diff byte size; all selected files remain in coverage
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • bounded analysis finalized with explicit task_done after reviewing all group files
  • … 39 additional warning(s) omitted.

1. AgentSourceCore/src/cursor-source-turn.ts (L37)

Making the new member optional leaves the hook path silently unfixed on the Memory runtime: Memory/src/agent-source/integration/workspace-bridge/runtime.ts:390 (and Memory/src/agent-source/adapters/cursor/vscdb-reader.ts:44) build CursorVscdbSource without conversationHeaders. For a Cursor 3.x chat whose inline fullConversationHeadersOnly is empty, the Memory-shipped hook therefore still returns identity_unresolved, while the updated backend scan captures the same chat — exactly the hook/scan identity divergence this module is meant to prevent. Consider making the method required so every implementer (including the Memory copies) fails to compile until it supplies the chunked rows, or factor the composerConvHeaders chunk reading into a shared helper both runtimes use.

💡 Suggested Change

Before:

  conversationHeaders?(composerId: string): readonly unknown[] | undefined;

After:

  conversationHeaders(composerId: string): readonly unknown[] | undefined;

2. App/backend/local-api-contracts/src/collab-instructions.ts (L75)

Entry-level .strict() means one unknown field (e.g. a newer writer adding authorId, or an older/newer CLI version sharing this file) makes safeParse fail, and parseCollabInstructionsFile then silently returns an empty file. This is not only a read-side problem: createCollabInstructionsService's load() also returns the empty file on parse failure, and remember()/updateScopes() immediately store() it back — so the next save rewrites the cache with a single project and permanently drops every other cached project's instructions. Dropping .strict() (Zod's default strip mode) makes the reader forward-compatible while still validating known fields.

💡 Suggested Change

Before:

}).strict().transform((entry) => ({

After:

}).transform((entry) => ({

3. App/backend/local-api-contracts/src/collab-instructions.ts (L240)

collabInstructionsText is the only place that enforces MAX_COLLAB_INSTRUCTIONS_CHARS (10k) on read, but no caller uses it: collabInstructionsFor returns entry.text verbatim, and all injection sites (readCollabProjectInstructions in workspace-bridge/runtime.ts, createCollabInstructionsSource) inject hit.entry.text raw. Because the schema allows MAX_COLLAB_INSTRUCTIONS_CHARS * 4 code units, text from a legacy/other-version file can be injected at 4x the intended size on every turn. Either have collabInstructionsFor return the capped text, or tighten the schema bound so the contract is actually enforced.


4. App/backend/local-api-contracts/src/collab-instructions.ts (L111)

Two problems with this normalizer, both affecting scope matching:

  1. collabInstructionsDirectoryKey("C:\\") strips the drive's trailing separator and yields "C:", a relative-looking path. collabInstructionsDirectoryCovers then derives the separator from root.includes("\\") ("C:" has none → "/"), so a scope anchored at a Windows drive root never covers C:\foo.
  2. It is a byte-for-byte duplicate of projectInstructionsKey in project-instructions.ts; a future fix to one will likely miss the other. Consider a single shared path-key helper.

5. App/backend/local-api-contracts/src/project-instructions.ts (L50)

Same failure mode as the collab schema: .strict() on the entry means a single unknown field invalidates the whole file, parseProjectInstructionsFile silently returns an empty file, and createProjectInstructionsService's load() returns that empty file to write(), which then store()s it — wiping the instructions of every other project on the machine. Dropping .strict() (Zod's default strip) keeps validation of known fields while remaining readable by other versions.

💡 Suggested Change

Before:

}).strict();

After:

});

6. App/backend/local-api-contracts/src/project-instructions.ts (L93)

projectInstructionsText has no caller anywhere in the repo: projectInstructionsAt returns the entry untouched, and both injection sites (readProjectInstructions in workspace-bridge/runtime.ts and createProjectInstructionsSource in memmy-agent) use entry.text.trim() directly. Since the schema accepts MAX_PROJECT_INSTRUCTIONS_CHARS * 4, the documented "兜住历史数据" cap is never applied. Either apply it at the read sites / inside projectInstructionsAt, or drop the helper and narrow the schema bound.


7. App/backend/src/adapters/inbound/local-api/routes/projects.ts (L48)

This 400 response bypasses withErrorEnvelope and omits requestId, but the desktop client parses errors with ApiErrorBodySchema, which requires error.requestId (NonEmptyStringSchema). The parse therefore fails and the user sees only Request PUT /api/projects/instructions failed with status 400 instead of the "背景与规范不超过 … 字" message. Throw an object carrying an ApiErrorCode so the shared envelope (code/message/requestId) is produced.

💡 Suggested Change

Before:

      return reply.status(400).send({ error: { code: "invalid_argument", message: `背景与规范不超过 ${MAX_PROJECT_INSTRUCTIONS_CHARS} 字` } });

After:

      throw Object.assign(new Error(`背景与规范不超过 ${MAX_PROJECT_INSTRUCTIONS_CHARS} 字`), { code: "invalid_argument" as const });

8. App/backend/src/adapters/inbound/local-api/server.ts (L224)

Detecting the "old cloud without projectOnly" case by matching the message prefix [HTTP 400] is brittle and over-broad: KnowledgeError collapses every non-401/404 status into 502, so the message text is the only signal available, but any HTTP 400 (bad query, invalid projectId, quota/param validation) is now swallowed and retried with the wider personal-scope, and the original error/status is lost without a trace. Narrow the condition to the specific unsupported-parameter response (e.g. also inspect the server message/code) and log the downgrade so a real 400 is not silently turned into broader evidence.


9. App/backend/src/adapters/inbound/local-api/routes/collab.ts (L230)

The dispatch decides the branch from a single body key while every branch schema is .strict(). A body carrying more than one recognised field (e.g. { name, instructions }, or { name, joinMode }) or a non-string instructions value falls into a branch whose strict schema rejects the extra key, so the client receives a generic "Unrecognized key" validation error instead of either applying the change or a clear message. Prefer a union/single schema (z.union([...]) or a discriminated check that rejects mixed payloads explicitly).


10. App/backend/local-api-contracts/src/collab-instructions.ts (L226-L227)

Scope ranking is encoded on one numeric scale whose tier offsets are only 100 apart while the path/id length is added freely, so tier precedence is not guaranteed:

  • a directory root longer than 100 chars (common on macOS/Windows, e.g. /Users/x/Library/Application Support/... or deeply nested repos) scores >200 and outranks a scope where this very session was associated individually — the more specific match loses;
  • a root longer than 236 chars even beats an exact unit match (300 + 36-char UUID), which is exactly the Codex case unitIsExact was introduced to protect.
    The comments contradict each other on the intended order as well: the docblock above and the comment on scopeWeight say unit > directory > session, while the comment right below says unit > session > directory.
    Suggestion: give each tier a wide stride (3_000_000 + len, 2_000_000, 1_000_000 + len for the directory branch on line 233) and align the two comments.
💡 Suggested Change

Before:

  if (sameSource && query.groupId && scope.groupId === query.groupId) return 300 + scope.groupId.length;
  if (query.conversationId && scope.groupId === query.conversationId) return 200;

After:

  if (sameSource && query.groupId && scope.groupId === query.groupId) return 3_000_000 + scope.groupId.length;
  if (query.conversationId && scope.groupId === query.conversationId) return 2_000_000;

11. App/backend/local-api-contracts/src/collab-instructions.ts (L168-L169)

Directory matching is case-sensitive, but Windows and default macOS filesystems are not. Scope directories come from the agents' config stores while the query directory comes from the host agent's cwd, so the casing can legitimately differ (C:\Users\me vs c:\users\me, or a differently-cased folder name recorded by another agent). In that case the lookup silently returns no instructions. The same issue exists in projectInstructionsKey/projectInstructionsAt in project-instructions.ts, which is a plain exact-key lookup.
Suggestion: fold case when the platform's filesystem is case-insensitive (e.g. process.platform === "win32" || "darwin") inside the normalizer, so both cover checks and key lookups agree.


12. App/backend/local-api-contracts/src/collab-instructions.ts (L122)

The dedupe key is built from the raw groupId, before normalization: "/repo/" and "/repo" therefore both survive as two scopes for the same unit. Conversely, when the same (sourceId, groupId) appears twice with different roots (e.g. a Cursor workspace listed twice with two root sets), the second scope is dropped together with its roots, so those directories silently lose coverage.
Suggestion: build the key from the normalized groupId and union the roots of duplicates instead of discarding them.


13. App/backend/src/adapters/inbound/local-api/routes/collab.ts (L172-L173)

A session id is validated with ProjectIdSchema, so a malformed session id is reported to the user as “项目 ID 无效” (and the same happens on the DELETE .../sessions/:sessionId route below). The pattern happens to be identical today, but the diagnostic is misleading and the two will silently diverge if either id format changes. Consider a dedicated SessionIdSchema (same regex, its own message) reused by both session routes.

💡 Suggested Change

Before:

  app.get<{ Params: { sessionId: string } }>("/api/collab/sessions/:sessionId", guard, withErrorEnvelope(async (request, reply) =>
    reply.send(await options.collab.readSessionDetail(ProjectIdSchema.parse((request.params as { sessionId: string }).sessionId)))));

After:

const SessionIdSchema = z.string().regex(/^[A-Za-z0-9-]{1,36}$/u, "任务 ID 无效");

  app.get<{ Params: { sessionId: string } }>("/api/collab/sessions/:sessionId", guard, withErrorEnvelope(async (request, reply) =>
    reply.send(await options.collab.readSessionDetail(SessionIdSchema.parse((request.params as { sessionId: string }).sessionId)))));

14. App/backend/src/services/collab-add-session.ts (L241-L243)

canonical compares paths as case-sensitive strings and strips all trailing separators. On Windows/macOS (case-insensitive filesystems) an agent-recorded workspacePath such as c:/users/me/repo will not equal process.cwd() (C:\Users\me\repo), so sessionInWorkspace returns false and memmy-project session add reports "找不到当前这条任务" for a task that is in fact the current directory. Likewise a filesystem root collapses to "" (/ → "", C:\ → C:), which then never matches anything. Note App/backend/src/adapters/outbound/agent-source/session-reference/local-session-rank.ts:isSameWorkspace already does a similar (POSIX-only) comparison — worth sharing one canonical helper instead of two ad-hoc ones.

💡 Suggested Change

Before:

function canonical(path: string): string {
  return path.replace(/\\/gu, "/").replace(/\/+$/u, "");
}

After:

const CASE_INSENSITIVE_PATHS = process.platform === "win32" || process.platform === "darwin";

function canonical(path: string): string {
  const normalized = path.trim().replace(/\\/gu, "/");
  // 根目录只剩一个分隔符,去掉就变成空串,两个根永远匹配不上。
  const trimmed = normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
  // Windows 与 macOS 默认大小写不敏感:同一个目录可能被报成不同大小写。
  return CASE_INSENSITIVE_PATHS ? trimmed.toLowerCase() : trimmed;
}

15. App/backend/src/services/collab-instructions-service.ts (L181-L185)

sameScopes uses only sourceId + groupId as the identity and ignores roots. But roots is exactly the part that changes while the unit id stays the same: instructionScopesFrom recomputes roots from the Cursor workspace file / Codex project, so when a .code-workspace gains a folder (or a Codex project gains a root), updateScopes/remember short-circuit here and the cached entry keeps the stale roots — the instruction is then never injected into the newly added folder, with no error or log. Include sorted roots in the comparison key (scopes are already normalized on both sides, so roots are populated consistently).

💡 Suggested Change

Before:

function sameScopes(left: readonly CollabInstructionScope[], right: readonly CollabInstructionScope[]): boolean {
  if (left.length !== right.length) return false;
  const keys = new Set(left.map((scope) => `${scope.sourceId}\u0000${scope.groupId}`));
  return right.every((scope) => keys.has(`${scope.sourceId}\u0000${scope.groupId}`));
}

After:

function sameScopes(left: readonly CollabInstructionScope[], right: readonly CollabInstructionScope[]): boolean {
  if (left.length !== right.length) return false;
  const keys = new Set(left.map(scopeKey));
  return right.every((scope) => keys.has(scopeKey(scope)));
}

/** 单位 id 相同但覆盖的目录变了(workspace 新增根、Codex 项目新增根),也算范围变了。 */
function scopeKey(scope: CollabInstructionScope): string {
  return `${scope.sourceId}\u0000${scope.groupId}\u0000${[...(scope.roots ?? [])].sort().join("\u0000")}`;
}

16. App/backend/src/services/collab-instruction-scopes.ts (L66-L69)

readCodexProjects opens the Codex state SQLite database and runs the projects/roots query on every call, and rootsFor is invoked once per Codex group. Since instructionScopesFrom is called per project in collab-service.ts's listProjects loop, a single board refresh opens the same DB once per Codex group in every project. Read the project table at most once per scope computation (identically for readWorkspaceRoots, which re-parses the same workspace file for repeated links).

💡 Suggested Change

Before:

  if (sourceId === "codex") {
    const projects = readCodexProjects(options.codexHome ?? join(homedir(), ".codex"));
    return projects?.find((project) => project.id === groupId)?.roots ?? [];
  }

After:

  if (sourceId === "codex") {
    // 同一次范围计算里只读一次 Codex 状态库:一个项目可能关联多个 Codex 分组。
    return codexProjects()?.find((project) => project.id === groupId)?.roots ?? [];
  }

17. App/backend/src/services/collab-instructions-service.ts (L210-L220)

Two problems in this block: (1) the source ids are hardcoded string literals while MEMMY_SCOPE_SOURCE is already exported by @memmy/local-api-contracts and used in collab-instruction-scopes.ts — if the constant changes, migrated entries carry a source id no reader matches and the migrated instruction silently stops being injected; (2) known is a snapshot taken before the loop and is never refreshed, so when two legacy directories carry the same cloudProjectId both branches migrate and the second remember overwrites the first entry — the first directory's scope is lost and migrated is over-counted. Merge scopes per project (or refresh known after each write).

💡 Suggested Change

Before:

    if (!known.projects[projectId]) {
      await input.collabInstructions.remember({
        projectId,
        scopes: [
          { sourceId: "memmy", groupId: directory },
          { sourceId: "*", groupId: directory }
        ],
        text
      });
      migrated += 1;
    }

After:

    const cached = known.projects[projectId];
    if (!cached) {
      await input.collabInstructions.remember({
        projectId,
        scopes: [
          { sourceId: MEMMY_SCOPE_SOURCE, groupId: directory },
          { sourceId: LEGACY_ANY_SOURCE, groupId: directory }
        ],
        text
      });
      migrated += 1;
      // 同一个云端项目可能镜像在多个目录:后面的目录要并进刚写好的条目,不能覆盖。
      known.projects[projectId] = (await input.collabInstructions.snapshot()).projects[projectId]!;
    } else if (!cached.scopes.some((scope) => scope.groupId === directory)) {
      await input.collabInstructions.remember({
        projectId,
        scopes: [...cached.scopes, { sourceId: MEMMY_SCOPE_SOURCE, groupId: directory }, { sourceId: LEGACY_ANY_SOURCE, groupId: directory }],
        text: cached.text
      });
    }

18. App/backend/package.json (L18)

postbuild runs chmodSync with no existence check, so any build where dist/src/cli/memmy-project.js is not emitted (renamed/removed entry point, tsconfig include narrowed, postbuild invoked directly) fails with ENOENT after the whole compile already succeeded; chmod can also throw EPERM on filesystems/mounts that don't support permission bits. Guard the target and don't fail an otherwise good build on a permission tweak.

💡 Suggested Change

Before:

    "postbuild": "node -e \"if (process.platform !== 'win32') require('node:fs').chmodSync('dist/src/cli/memmy-project.js', 0o755)\"",

After:

    "postbuild": "node -e \"const fs = require('node:fs'), f = 'dist/src/cli/memmy-project.js'; if (process.platform !== 'win32' && fs.existsSync(f)) { try { fs.chmodSync(f, 0o755) } catch (error) { console.warn('chmod skipped for ' + f + ': ' + error.message) } }\"",

19. App/backend/src/adapters/outbound/agent-source/cursor/vscdb-reader.ts (L83-L86)

This query introduces two failure modes that abort the whole Cursor source, and both are silent for callers (e.g. readRecentSessions wraps adapter.scan in try/catch and returns [], so Cursor just disappears from the list):

  1. lastUpdatedAt is a new column dependency — this is the only place in the repo referencing it, and the existing fixtures (cursor/tests/adapter.test.ts:230) create composerHeaders(composerId, isSubagent, subagentTypeName) without it. On any store lacking that column, db.prepare throws in createSource and no chat is read at all. The Hermes reader in this same PR guards its new title column with tableColumns(...).has("title"); Cursor should do the same and fall back to the unordered query.
  2. LIMIT ${...} builds SQL by interpolation; Math.trunc(NaN)/Infinity yields LIMIT NaN, which throws at prepare time. ScanOptions.maxConversations is a public optional number, so bind it as a parameter instead.

Please also add a test that scans the cursor adapter with { fullHistory: true, order: "recent_first", maxConversations: N } against a fixture, since this path is currently uncovered.

💡 Suggested Change

Before:

  const mainComposerQuery = "SELECT composerId FROM composerHeaders WHERE isSubagent = 0 AND composerId IS NOT NULL";
  const mainComposers = recentLimit === undefined
    ? db.prepare(mainComposerQuery)
    : db.prepare(`${mainComposerQuery} ORDER BY lastUpdatedAt DESC LIMIT ${Math.max(1, Math.trunc(recentLimit))}`);

After:

  const mainComposerQuery = "SELECT composerId FROM composerHeaders WHERE isSubagent = 0 AND composerId IS NOT NULL";
  // Older Cursor schemas have no lastUpdatedAt; ordering by it would throw and drop the whole source.
  const composerLimit = recentLimit === undefined ? null : Math.max(1, Math.trunc(recentLimit));
  const ordersByRecency = composerLimit !== null && tableColumns(db, "composerHeaders").has("lastUpdatedAt");
  const mainComposers = ordersByRecency
    ? db.prepare(`${mainComposerQuery} ORDER BY lastUpdatedAt DESC LIMIT ?`)
    : db.prepare(mainComposerQuery);
  const mainComposerRows = () => (ordersByRecency
    ? mainComposers.all(composerLimit as number)
    : mainComposers.all()) as Array<{ composerId: string }>;

20. App/backend/src/services/collab-instruction-scopes.ts (L67)

The Codex home is hardcoded to ~/.codex, but everywhere else in the backend (agent paths, session grouping, the workspace-bridge hook) it goes through resolveCodexHomeDirectory(), which honours CODEX_HOME. When a person has CODEX_HOME set, the group ids stored in the scopes come from that database while this lookup reads ~/.codex, so the project id never resolves and the Codex scope is written without roots — directory fallback matching silently stops working for those projects. collab-service.ts never passes options.codexHome in production, so the fallback is the only path taken.

💡 Suggested Change

Before:

    const projects = readCodexProjects(options.codexHome ?? join(homedir(), ".codex"));

After:

    const projects = readCodexProjects(options.codexHome ?? resolveCodexHomeDirectory());

21. App/backend/src/services/collab-instructions-service.ts (L204)

One bad entry aborts the whole migration: remember() rejects on any write error (and on "没有可生效的会话范围"), forgetLegacy() rejects on any write error, and both are awaited without a guard here. index.ts awaits this call during startup with no try/catch, so a single EACCES/ENOSPC on collab-project-instructions.json or project-instructions.json prevents the backend from starting at all. It also leaves that entry's old local mirror in place, which is exactly the double-injection the migration exists to remove. Skipping and logging a failing entry (and continuing with the rest) keeps startup and the migration working.

💡 Suggested Change

Before:

  for (const entry of input.legacyEntries) {

After:

  for (const entry of input.legacyEntries) {
    // 单条失败不该拖垮启动:跳过并记日志,下次启动还会再试。

22. App/backend/src/services/collab-service.ts (L1490-L1492)

createProject persists workingDirectory but never sets directoryChosen. Later ensureWorkingDirectory computes chosen: Boolean(directory) || (Boolean(existing) && stored.directoryChosen), so a project whose folder was picked by its creator reports chosen: false. The board (app-frame.tsx) then registers that folder in blank mode instead of existing, which fails (409 project_directory_not_empty) when the folder is non-empty and holds no project record, and otherwise adopts whatever local project is registered on it — the shared project ends up with no tasks or with another project's tasks. Record whether the directory was supplied (e.g. it is outside the managed onlineWorkdirRoot) when creating the project.

💡 Suggested Change

Before:

      const stored = await readLinks(project.id, account.accountId);
      stored.workingDirectory = localDirectory;
      await writeLinks(stored);

After:

      const stored = await readLinks(project.id, account.accountId);
      stored.workingDirectory = localDirectory;
      // 目录是本人选的(而不是 Memmy 生成的工作目录)时记下来,ensureWorkingDirectory 才能如实回答 chosen。
      stored.directoryChosen = Boolean(directory?.trim());
      await writeLinks(stored);

23. App/backend/src/services/collab-service.ts (L1604-L1606)

The deletions/exclusions made in this loop are discarded: stored.groups = links.groups; stored.sessions = links.sessions; a few lines below overwrite both, so the goal stated in the comment ("persist each confirmed deletion and exclusion to prevent background sync recreating it") is not achieved for group-covered sessions — the next group sync can re-upload a session the person just removed. The loop also rewrites and re-serialises the whole links file once per removed session, exposing half-updated state to unlocked readers (cloudProjectForDirectory). Accumulate the removals, merge the exclusions into the incoming links.groups, and write the file once after the loop.

💡 Suggested Change

Before:

          // Persist each confirmed deletion and exclusion to prevent background sync recreating it.
          await writeLinks(stored);
          removed += 1;

After:

          // 先累积,循环结束后并入本次选择,只写一次文件。
          removed += 1;

24. App/backend/src/services/collab-service.ts (L1158-L1159)

Every read or parse failure is treated as "no links yet", which resets synced, removedSessions and sharedSessions. A corrupt or half-written links.json therefore makes the next sync re-upload every task (duplicate cloud copies with new ids, and the cloud-id→conversation mapping used by withLocalConversation is lost) and resurrects tasks the person deleted from the project. Distinguish a missing file from an unreadable/invalid one, and at least log (or quarantine the file) before falling back to the empty store.


25. App/backend/src/services/collab-service.ts (L1180-L1182)

The temp file name is fixed per process, and several writers bypass the lock (createProject, bindLocalProject, ensureWorkingDirectory, plus releaseBinding's read-modify-write which reads outside exclusive). Two concurrent writers can write the same temp path, so one rename publishes the other's snapshot or fails with ENOENT, and a stale snapshot can overwrite a newer one. Route every write through exclusive (or use a unique temp name per write) and move releaseBinding's read inside the lock.


26. App/backend/src/services/collab-service.ts (L1777-L1778)

directory is never read; the cache is written from instructionScopes(stored) only. This contradicts the interface comment ("directory is the local project's folder … so the copy lands where this computer's tasks read it") and the route, which validates an absolute path for it — callers pass a value that is silently dropped. Either use it (e.g. add a Memmy scope for that folder) or remove the parameter from the signature, the route and the doc.

💡 Suggested Change

Before:

    async updateInstructions(projectId, text, directory) {
      const instructions = text.trim();

After:

    async updateInstructions(projectId, text) {
      const instructions = text.trim();

27. App/backend/src/services/collab-service.ts (L1374)

Construction-time fire-and-forget: every createCollabService recurses rm -rf over projectsDir/*/sessions without awaiting, logging, or checking that these are Memmy's own legacy caches. With a wrong MEMMY_HOME/projectsDir (or if another feature ever uses a sessions folder there) user data is deleted silently, and the deletion races the first syncs of the same directories. Run this once from the composition root (startup) where it can be awaited and its errors logged.


28. App/backend/src/services/collab-service.ts (L1582-L1584)

The global links lock is held across listLocalSessions (which on the first call performs a full scan of every agent store — the reader documents this as taking seconds) and across projectTaskSessions (parsing every task file). Any other project's save, delete or bind waits for it. Read the local listing and task files before entering exclusive, then take the lock only for the links read-modify-write, as uploadPending/recordUploaded already do.


29. App/backend/src/services/collab-service.ts (L1045-L1048)

Every call parses all task files sequentially, and this runs per project in saveLinks, runUpload, syncLinkedSessions and listProjectTasks. With a few hundred tasks the serial reads dominate each sync. Read them in parallel with Promise.all (keeping the project-id filter) and consider caching the parsed metadata per localProjectId.


30. App/backend/src/services/collab-service.ts (L2377)

On Windows join() yields backslashes, so this fallback (used when the metadata line carries no key) returns the whole path as the conversation id; applyTaskSharing/shareCurrentTask then look for websocket_<id>.jsonl and never find the task. Use basename(path) from node:path instead of splitting on /.

💡 Suggested Change

Before:

  const conversationId = key.replace(/^websocket:/u, "") || path.split("/").pop()!.replace(/^websocket_/u, "").replace(/\.jsonl$/u, "");

After:

  const conversationId = key.replace(/^websocket:/u, "") || basename(path).replace(/^websocket_/u, "").replace(/\.jsonl$/u, "");

31. App/backend/src/services/collab-service.ts (L2635)

.. survives this sanitiser unchanged, so linksPath("..") resolves to projectsDir/../links.json and reads/writes the links file outside the projects root. Routes validate project ids today, but this is the last line of defence for ids coming from links.json and cloud payloads. Reject ./.. explicitly.

💡 Suggested Change

Before:

  return value.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 120) || "_";

After:

  const segment = value.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 120);
  return segment && segment !== "." && segment !== ".." ? segment : "_";

32. App/backend/src/adapters/outbound/agent-source/cursor/vscdb-reader.ts (L39)

readCursorComposer calls source.composerData(composerId) again internally, so each chat's composerData:{id} JSON is parsed twice per scan. On older Cursor stores composerData embeds the whole header list, so on a database-wide scan this doubles the most expensive JSON parse per conversation. Consider memoising the parsed value per composer in createSource (or letting the shared parser surface the title) instead of re-reading it here.


33. App/backend/src/adapters/outbound/agent-source/claude-code/adapter.ts (L74)

The title is read eagerly for every discovered session, before it is known whether the session emits any message. readJsonlSessionTitle reads up to 512KB from the tail and another 512KB from the head, and takes no AbortSignal, so an aborted scan or a session whose messages are all filtered out by options.since/maxMessages still pays that I/O. Since the title is only used when a message is yielded, resolve it lazily on the first emitted message and honour the abort signal before doing the read.

💡 Suggested Change

Before:

        const sessionTitle = await readJsonlSessionTitle(session.sessionFilePath);

After:

        let sessionTitle: string | null | undefined;
        for await (const rawMessage of streamConversationWindow(
          readClaudeCodeTranscript(session.sessionFilePath, options.signal),
          options.since,
          options.signal,
          remainingMessageCapacity(options.maxMessages, emittedMessages),
          options.fullHistory
        )) {
          throwIfAborted(options.signal);
          if (sessionTitle === undefined) {
            sessionTitle = await readJsonlSessionTitle(session.sessionFilePath);
          }

34. App/backend/src/adapters/outbound/agent-source/workbuddy/history-reader.ts (L40-L45)

This hand-rolled title extraction duplicates the shared createJsonlTitleTracker() in ../jsonl-session-title.ts, which was added in this same change set and is already used by the QwenWork reader for exactly this streaming case (titles.observe(record) / titles.current()). Beyond the duplication, the local version only understands ai-title, so a user-renamed conversation (custom-title) or an agent-name row would be ignored, and the kind priority (custom-title > ai-title > agent-name) that the shared helper implements is lost. Prefer reusing the tracker so all JSONL sources resolve titles the same way.

💡 Suggested Change

Before:

/** WorkBuddy's own title record: `{"type":"ai-title","aiTitle":"…"}`. */
function workbuddyGeneratedTitle(record: Record<string, unknown>): string | null {
  if (record.type !== "ai-title") {
    return null;
  }
  const title = firstString(record.aiTitle, record.title);

After:

// in readWorkbuddyHistory:
//   const titles = createJsonlTitleTracker();
//   if (titles.observe(record)) continue;
//   ... const sessionTitle = titles.current();

35. App/backend/src/adapters/outbound/agent-source/jsonl-session-title.ts (L63-L65)

When the session file is no larger than the tail window (the common case for Claude Code transcripts), the head fallback re-reads the exact same bytes: readTitleWindow(filePath, "tail") already covered the whole file, so an untitled session costs a full extra open/stat/read/close plus a second parse of every line. Since readJsonlSessionTitle is awaited per session in claude-code/adapter.ts before the turn loop, this doubles the title-probe I/O for every untitled session in a scan. Consider letting readTitleWindow report whether the window covered the whole file (e.g. return { title, complete }) and skipping the head pass when it did; while at it, the probe could accept the caller's AbortSignal so an aborted scan stops reading title windows.


36. App/backend/src/adapters/outbound/agent-source/jsonl-session-title.ts (L29-L30)

The documented contract ("the title this row carries") does not match the implementation: observe returns current(), i.e. the best-priority title seen so far. A matching agent-name row returns an earlier custom-title, so callers cannot tell the difference between "this row's own title" and "a title already existed". The qwenwork consumer relies on the truthiness of this value to decide the row is not a turn, so the semantic overloading is easy to break later. Either return an explicit indicator for the row (and expose current() separately, as already done) or document the actual semantics.

💡 Suggested Change

Before:

  /** The title this row carries, if any; `null` for every other row. */
  observe(record: Record<string, unknown>): string | null;

After:

  /** The title this row makes current when it is itself a title row; `null` for every other row. */
  observe(record: Record<string, unknown>): string | null;

37. App/backend/src/adapters/outbound/agent-source/hermes/state-db-reader.ts (L41-L43)

readSessionTitles is byte-for-byte identical to the new readSessionTitles in opencode/db-reader.ts (same tableColumns probe, same SELECT id, title, same \s+ collapsing, only the table name differs), and the surrounding workspace/title merge in the generator loop is duplicated as well. The whitespace collapsing also duplicates oneLineText in jsonl-session-title.ts. Worth extracting one shared helper (e.g. sqliteSessionTitles(db, tableName)) so a schema or normalization change only has to be made once.


38. App/backend/src/adapters/outbound/agent-source/opencode/db-reader.ts (L48)

The table is hardcoded to session, but createOpencodeDatabaseSource reads session_v2 (with session_message) whenever that schema is present. On a database migrated to V2, session is either absent (so PRAGMA table_info(session) is empty and no title is ever emitted) or is the retained legacy table (so titles can be stale/missing for sessions created after the migration) while the turns come from session_v2. Resolve the title table the same way the parser does.

💡 Suggested Change

Before:

  for (const row of db.prepare("SELECT id, title FROM session").all() as unknown as Array<{ id: string; title: string | null }>) {

After:

  const table = hasTable(db, "session_v2") ? "session_v2" : "session";
  if (!tableColumns(db, table).has("title")) return titles;
  for (const row of db.prepare(`SELECT id, title FROM ${table}`).all() as unknown as Array<{ id: string; title: string | null }>) {

39. App/backend/src/adapters/outbound/agent-source/qwenwork/history-reader.ts (L31-L32)

Titles are only attached to messages that stream after the title row. QwenWork files are Claude-Code-shaped (same user/assistant/isSidechain/origin records), and those stores append/rewrite the title record at the end of the file as the conversation progresses — which is exactly why the Claude Code adapter uses readJsonlSessionTitle() (tail-then-head window scan) instead of streaming attachment. If the title row lands after the last message here, titles.current() stays null and every message loses the title, so the picker falls back to the first-question heuristic. Consider reading the title once per file via readJsonlSessionTitle(filePath) (or falling back to it after the stream ends) instead of relying on row order.


40. App/backend/src/adapters/outbound/agent-source/pi/history-reader.ts (L26)

The one-line normalisation (replace(/\s+/gu, " ").trim()) is now duplicated here, in opencode/db-reader.ts and in workbuddy/history-reader.ts, while jsonl-session-title.ts already has a private oneLineText() doing the same thing. Export it (e.g. normalizeSessionTitle) and reuse it so the three readers cannot drift apart.


41. App/backend/src/adapters/outbound/agent-source/workbuddy/history-reader.ts (L35)

New title behavior has no test coverage. The existing workbuddy/tests/adapter.test.ts fixtures contain no ai-title row, so neither the title record being filtered out of the message stream nor the sessionTitle propagation into ConversationMessage (adapter.ts) is asserted. A fixture with {"type":"ai-title","aiTitle":"…"} and an assertion that it produces no extra message plus sessionTitle on later messages would lock in both behaviors.


42. App/backend/src/adapters/outbound/agent-source/jsonl-session-title.ts (L67-L68)

The tail and head windows are resolved independently and the first non-null wins, so the priority rules this file documents (TITLE_KINDS ordering, "the last title row wins") only hold within one window. For a session larger than the window, the tail can hold an ai-title while a higher-priority custom-title sits in the head — the head is then never consulted, returning the lower-priority name the person replaced; symmetrically, when both windows are empty neither sees a title written in the middle of the file, and the head may hand back a title that was later superseded. Consider feeding both windows through one tracker (head first, then tail) and letting current() pick the winner, which also makes the head read conditional on the tail not having covered the whole file.

💡 Suggested Change

Before:

async function readTitleWindow(filePath: string, from: "head" | "tail"): Promise<string | null> {
  const windowBytes = from === "tail" ? TAIL_WINDOW_BYTES : HEAD_WINDOW_BYTES;

After:

async function readTitleWindow(filePath: string, from: "head" | "tail"): Promise<string | null> {
  const windowBytes = from === "tail" ? TAIL_WINDOW_BYTES : HEAD_WINDOW_BYTES;

43. App/backend/src/adapters/outbound/agent-source/hermes/state-db-reader.ts (L29)

The new title path is untested: createStateDbFixture in hermes/tests/adapter.test.ts creates sessions(id, cwd, started_at) with no title column, so readSessionTitles always returns an empty map and neither the whitespace collapsing / blank-title skipping nor the per-session mapping (titles.get(message.conversationId)) is exercised. Adding a title column to the fixture (asserting sessionTitle shows up on the staged messages) plus a schema-without-title case would cover both the new branch and the older-schema fallback this comment claims.


44. App/backend/src/adapters/outbound/agent-source/qwenwork/history-reader.ts (L3)

These readers are mirrored one-for-one under Memory/src/agent-source/adapters/qwenwork|pi|opencode/ (the previous files were byte-identical apart from this change), and Memory/src/agent-source/runtime.ts still registers those copies via @memmy/memory. Only the App/backend copies are updated here, so OpenCode/Pi/QwenWork conversation titles now silently differ depending on which package performs the scan. Please apply the same change to the mirrored readers (or de-duplicate them) so the behavior cannot drift per package.


45. App/backend/src/adapters/outbound/agent-source/pi/adapter.ts (L80)

None of the existing adapter tests (pi/tests/adapter.test.ts, qwenwork/tests/adapter.test.ts) assert sessionTitle, and there is no test covering readSessionTitles in opencode/db-reader.ts. Since the title is now user-visible metadata, add assertions for these three passthroughs (including a title-less fixture, so the field is absent rather than empty) to lock the behavior in.


46. App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts (L301)

The allow-list omits session share, which the CLI supports (App/backend/src/cli/memmy-project.ts USAGE: memmy-project session share [--project ...] [--off]) and which is used by the collab flow. With the stated intent ("Scope permissions to the operations the CLI currently supports"), an agent re-sharing/unsharing a task will still hit a permission prompt even though session add is auto-allowed.

💡 Suggested Change

Before:

  ["list", "knowledge search", "session read", "session search", "session add"].map((action) => `Bash(${path} ${action}:*)`)

After:

  ["list", "knowledge search", "session read", "session search", "session add", "session share"].map(
    (action) => `Bash(${path} ${action}:*)`
  )

47. App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts (L292-L293)

This path list duplicates the CLI locations documented in templates/memmy-project-skill.ts (lines ~46-53). If one side changes (a new install location, a renamed binary), the allow rules silently stop matching the command the skill tells the agent to run, so every memmy-project call falls back to a permission prompt — a drift that no test will catch. Consider exporting the paths once (e.g. from the template module) and reusing them here. Also, the doc comment above describes operations rather than paths, which is a bit misleading.


48. App/backend/src/adapters/outbound/agent-source/session-reference/local-session-rank.ts (L50-L56)

Workspace comparison is POSIX-only. workspacePath reaches this module straight from the adapters without separator normalization (e.g. Cursor pushes fileURLToPath(...), Codex/Hermes/opencode push raw cwd), so on a Windows host it looks like C:\repo. Then cwd.replace(/\/+$/u, "") leaves C:\repo\ untouched and startsWith(${base}/) never matches, so the "same directory ranks first" tie-breaker is silently dead on Windows (it is the only thing the cwd context is used for). Please normalize with node:path instead of hand-rolling /-based comparisons.

💡 Suggested Change

Before:

export function isSameWorkspace(session: LocalSession, cwd: string | null): boolean {
  if (!cwd || !session.workspacePath) {
    return false;
  }
  const base = cwd.replace(/\/+$/u, "");
  return session.workspacePath === base || session.workspacePath.startsWith(`${base}/`);
}

After:

  const base = resolve(cwd);
  const target = resolve(session.workspacePath);
  return target === base || target.startsWith(`${base}${sep}`);

49. App/backend/src/adapters/outbound/agent-source/session-reference/session-group.ts

Same POSIX-only assumption, with three consequences on Windows hosts: (1) codexProjectGroup never matches a project root, so every Codex session loses its project group; (2) directoryGroup's replace(/\/+$/u, "") does not strip a trailing \; (3) WORKBUDDY_PLAYGROUND only matches /WorkBuddy/<ts>, so WorkBuddy one-off task folders stop being excluded. Note readWorkspaceRoots a few lines below already handles C:\ explicitly, so this is an inconsistency inside the same file. Suggest comparing normalized absolute paths via node:path.


50. App/backend/src/adapters/outbound/agent-source/session-reference/local-session-reader.ts (L253-L258)

The bare catch { return []; } collapses every failure into "this agent has no sessions". Callers cannot tell an unreadable store / adapter bug apart from an empty store, and for read() the empty result is then turned into LocalSessionReadError("not_found"), which collab-service maps to a 404 "session not found" (see readLocal). A native-module or DB-permission failure would be reported to the user as a missing session, making it very hard to diagnose. Consider logging the error and distinguishing "read failed" from "not found".


51. App/backend/src/adapters/outbound/agent-source/session-reference/local-session-reader.ts (L227)

readSessions re-implements the whole scan orchestration of readRecentSessions (detect → createSessionGrouper → iterate adapter.scan with the recent_first window → lazily create SessionBuilder → finishSession), differing only in the conversation filter and the window widening loop. Any later change to the scan options (e.g. fullHistory, maxScanTargets, message capacity) or to SessionBuilder/appendMessage has to be mirrored in both places, and a miss will silently make the picker and the upload path disagree about a session's turns. Suggest extracting one scanBuilders(adapter, { window, filter }) helper that both functions consume.


52. App/backend/src/adapters/outbound/agent-source/session-reference/local-session-rank.ts (L45-L47)

matchesQuery has no caller anywhere in the repo (ranking only uses matchScore via the inline filter), so it is dead code. Either wire it up or drop it.


53. App/backend/src/adapters/outbound/agent-source/session-reference/local-session-reader.ts (L400-L402)

Hardcoded "zh-CN" locale for the fallback title date. This is backend code that renders user-visible picker text, and the rest of the module keeps titles locale-neutral; on an English/non-Chinese build the picker will show Chinese-formatted dates (10/21 vs 21/10 ambiguity aside, the format is fixed per this literal). Consider passing a locale in (or using a locale-free MM-DD format).


54. App/backend/src/adapters/outbound/skill-writer/skill-only-target.ts (L43)

This removal is undone on the next launch: refreshInstalledSkills() (backend startup, index.ts) unconditionally calls ensureProjectSkills(), which rewrites skills/memmy-project into every agent home whose directory exists — including the one the user just uninstalled. So uninstall() reports the skill gone, while it reappears after a restart. Same applies to workbuddy/target.ts and opencode/target.ts which follow the same pattern. If uninstall is meant to be durable, ensureProjectSkills() needs to skip agents the user is not connected to (or a per-agent "uninstalled" flag needs to be persisted); otherwise this call is misleading.


55. App/backend/src/adapters/outbound/skill-writer/skill-directory.ts (L118-L119)

readInstalledSkillKind detects the installed variant by sniffing heading literals that are declared inline inside templates/memmy-plugin.ts and templates/memmy-default.ts. If either heading is reworded, this silently returns null, refreshInstalledSkill then reports "nothing to do", and already-connected agents stop receiving updated instructions with no error anywhere. Suggest exporting these markers from the template modules (or asserting the detection against the real rendered manifests in a unit test) so the coupling is enforced instead of implicit.


56. App/backend/src/adapters/outbound/skill-writer/skill-directory.ts (L64-L67)

The readFile(...).catch(() => null) read-or-null idiom is now repeated in three new functions (isMemmySkillDirectoryCurrent, isMemmyProjectSkillDirectoryCurrent, readInstalledSkillKind). Extracting a small readTextFileOrNull(path) helper keeps the three comparisons from drifting apart (e.g. one of them later switching to "" on ENOENT, which would make content comparisons inconsistent).


57. App/backend/src/adapters/outbound/skill-writer/claude-code/target.ts (L285-L287)

These allow rules are the only thing that lets an agent run the new memmy-project skill without a permission prompt, but they are written exclusively from upsertClaudeCodeHookSettings, i.e. only when installPlugin runs. The other paths that put the project skill on disk never reach this code:

  • skillDistributionService.refreshInstalledSkill() calls installPlugin only when the on-disk memory skill differs from this build (renderMemmyPluginSkillManifest is unchanged in this PR), otherwise it just does replaceMemmyProjectSkillDirectory(...);
  • ensureProjectSkills() (run at startup via agentSourceService.refreshInstalledSkills()) only writes skills/memmy-project.

Consequently an existing Claude Code plugin user who upgrades gets skills/memmy-project/SKILL.md while settings.json still has no Bash(<path> ...:*) rules, so every command the skill instructs prompts for approval. Consider reconciling the rules independently of the memory-skill freshness check (e.g. a dedicated ensure step, or include the permission rules in the currency check so installPlugin is re-entered).


58. App/backend/src/adapters/outbound/skill-writer/hermes/target.ts (L78)

The new cleanup sits after the pre-existing if (!existing.includes(START_MARKER)) return; guard, so it is skipped whenever SOUL.md no longer carries Memmy's marker (user edited/removed it, or the profile was written by another path). Uninstalling then leaves skills/memmy-project/SKILL.md on disk, so the agent keeps the project skill after Memmy was disconnected. The other targets (claude-code, codex, cursor, deepseek-harness, openclaw) remove the skill directories unconditionally — suggest moving the two removeMemmy*SkillDirectory calls above the marker guard so both variants are always cleaned up.

💡 Suggested Change

Before:

      await removeMemmyProjectSkillDirectory(root);

After:

      await writeFileAtomically(filePath, removeMarkerBlock(existing));
      await removeMemmySkillDirectory(root);
      await removeMemmyProjectSkillDirectory(root);

59. App/backend/src/adapters/outbound/agent-source/session-reference/local-session-reader.ts (L216-L217)

The comment assumes adapter.scan({ order: "recent_first" }) yields conversations in global recency order, but that only holds for adapters that discover sessions globally (e.g. claude-code's discoverClaudeSessions). Cursor instead iterates scan targets one after another (workspaces sorted by state-db mtime, then the global store) and applies maxConversations per target, so the first N conversations in builders are all from the newest workspace. With ≥limit chats there, every chat from the other workspaces (and from the global store, whose workspace mapping session-group.ts was added for) is silently dropped before available() ever gets a chance to rank by updatedAt. Consider sorting by updatedAt before slicing, or collecting until limit distinct conversations exist, instead of relying on scan order.

💡 Suggested Change

Before:

    // 扫描按最近优先返回,多出来的都是更早的任务。
    return sessions.slice(0, limit);

After:

    // 各 Agent 的扫描顺序不一定全局按时间:按活跃时间重排后再截断,避免丢掉其它工作区的会话。
    return sessions
      .sort((left, right) => lastActivityMs(right) - lastActivityMs(left))
      .slice(0, limit);

60. App/backend/src/adapters/outbound/agent-source/session-reference/session-group.ts (L129)

This prepares a statement against a workspaceId column of the global composerHeaders table. Cursor's schema varies by version (the repo's own global fixture declares composerHeaders(composerId, isSubagent, subagentTypeName) only), and a missing column makes prepare throw, which the surrounding catch turns into "no chat has a workspace at all" — the whole Cursor grouping feature silently degrades with no signal. Consider probing PRAGMA table_info(composerHeaders) first and skipping the mapping when the column is absent.


61. App/backend/src/adapters/outbound/agent-source/session-reference/session-group.ts (L151)

readCursorWorkspacePath re-implements readWorkspacePath/normalizeWorkspacePath from cursor/workspace-discovery.ts (same workspace.json parse, folder before workspace, same file:// handling). Extracting one shared helper avoids the two copies drifting apart when Cursor's format changes.


62. App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts (L62-L65)

This early return in the prompt-submit branch replaces the rest of main() for every non-Claude-Code agent (Codex UserPromptSubmit, Cursor beforeSubmitPrompt). Whenever the workspace has project instructions, the function now returns here and never reaches startCapturedTurn(...) / writeTurnStartOutput(...) at the bottom, so the per-turn memory context injection (and the turn-state write) is silently skipped for exactly the common case (a session inside a project). Project instructions should be merged into the turn-start output alongside memory/knowledge rather than short-circuiting capture — e.g. compute project context and pass it into writeTurnStartOutput, similar to how handleL3LifecycleEvent concatenates sections.


63. App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts (L862)

The containment test hardcodes a forward slash. Codex runs on Windows too, where both target and root use \, so target.startsWith(root + "/") never matches a nested root and the function returns null. The caller then falls back to plain directory matching, which loses the nesting protection unitIsExact was added for (a nested Codex project can pick up another project's instructions). Reuse the existing separator-aware collabInstructionsDirectoryCovers(root, target) helper from @memmy/local-api-contracts instead of the ad-hoc prefix check.

💡 Suggested Change

Before:

      if (target !== root && !target.startsWith(`${root}/`)) continue;

After:

      if (!collabInstructionsDirectoryCovers(root, target)) continue;

64. App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts (L283)

projectKnowledgeContext is awaited in main() before the hook emits anything, and this call has a 25s timeout with no budget — unlike the L3 load, which is capped by LIFECYCLE_CONTEXT_BUDGET_MS (3s). A slow or wedged local backend therefore adds up to 25s of dead time to every user message. Consider a much shorter timeout here (or wrap the call with the same 3s budget) so knowledge recall can never stall prompt submission.


65. App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts (L216-L221)

Promise.race does not cancel the losing promise, and no rejection handler is attached to promise. Because openHookRuntimeSession/loadRuntimeL3 are themselves given the same 3s timeout, they can reject right after the race has already resolved null, surfacing as an unhandled rejection that can terminate the hook with a non-zero exit. Attach a .catch() to the raced promise (the timeout branch already treats a failure as "no content"). Note also that openHookRuntimeSession forwards timeoutMs and is then wrapped again by withinBudget at the same value — the double budgeting orphans the in-flight request.

💡 Suggested Change

Before:

function withinBudget(promise, ms) {
  return Promise.race([
    promise,
    new Promise((resolve) => setTimeout(() => resolve(null), ms))
  ]);
}

After:

function withinBudget(promise, ms) {
  return Promise.race([
    promise.catch(() => null),
    new Promise((resolve) => setTimeout(() => resolve(null), ms))
  ]);
}

66. App/backend/src/adapters/outbound/skill-writer/templates/memmy-project-skill.ts (L77)

The command card lists only session add and session add --project, but the "把当前任务加入项目" rules and the error table tell the agent to use --hint "<原话>" — it is never shown in the card, so an agent following the card will not know the flag exists. Also the Windows example a few lines above runs "...\memmy-project.cmd" projects, but the CLI only implements list (see USAGE in cli/memmy-project.ts); projects would throw 未知命令. Align the documentation with the actual CLI surface (list, session add [--project] [--hint]).


67. App/backend/src/cli/memmy-project.ts (L75-L76)

parseArgs cannot tell boolean flags from value flags, so a boolean flag silently eats the following word. memmy-project session share --off "Memmy 开发" parses as off = "Memmy 开发", and since off is only truthy for true/"true" the command ends up sharing the task instead of stopping the share, with no error (the stray word is also dropped, so the "不接受额外参数" guard never fires). Declaring the boolean flags (all, off, help) keeps them from consuming a positional token.

💡 Suggested Change

Before:

    const next = argv[index + 1];
    options[raw] = next !== undefined && !next.startsWith("--") ? argv[++index] ?? "" : true;

After:

    const next = argv[index + 1];
    options[raw] = BOOLEAN_OPTIONS.has(raw) || next === undefined || next.startsWith("--") ? true : argv[++index] ?? "";

68. App/backend/src/services/project-directory.ts (L29-L31)

The throw new Error("not a directory") never reaches the caller: it is thrown inside the try whose catch swallows it and calls mkdir(..., { recursive: true }), which then fails with EEXIST. So the intended message is dead code and the caller gets a raw EEXIST/ENOTDIR instead, contradicting the documented "refuses a path that exists as a file" contract (the test only asserts rejects.toThrow(), so it passes either way). Only fall back to mkdir on ENOENT cut.

💡 Suggested Change

Before:

    if (!info.isDirectory()) throw new Error("not a directory");
  } catch {
    await mkdir(target, { recursive: true });

After:

    if (!info.isDirectory()) throw new Error(`${target} 已存在,且不是文件夹`);
    return target;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
    await mkdir(target, { recursive: true });

69. App/backend/src/cli/memmy-project.ts (L303-L305)

parseTurnCoordinate accepts #0 (the regex only requires digits), so session read a1b2c3#0 sends { turns: { from: 0, to: 0 } }, which the backend rejects with a raw zod error. --turns/--around go through turnNumber, which enforces >= 1 and a friendly message, so the two paths behave inconsistently for an invalid coordinate. Validate the lower bound here as well.

💡 Suggested Change

Before:

  const match = /^(.*?)#(\d+)$/u.exec(reference.trim());
  if (!match) return { reference: reference.trim() };
  return { reference: (match[1] ?? "").trim(), turn: Number.parseInt(match[2] ?? "", 10) };

After:

  const match = /^(.*?)#(\d+)$/u.exec(reference.trim());
  if (!match) return { reference: reference.trim() };
  const turn = Number.parseInt(match[2] ?? "", 10);
  if (turn < 1) throw new Error("任务 ID 后的轮次需要从 1 开始");
  return { reference: (match[1] ?? "").trim(), turn };

70. App/backend/src/cli/memmy-project.ts (L351-L353)

defaultRuntimeConfigPath reads process.env.MEMMY_HOME directly, while every other environment lookup in this file goes through deps.env ?? process.env. A caller (tests, embedders) that passes env: { MEMMY_HOME: '<tmp>' } to isolate itself will still read the real ~/.memmy/runtime.json and can hit the developer's running Memmy, so deps.env is only half honored. Thread env in as a parameter.

💡 Suggested Change

Before:

function defaultRuntimeConfigPath(): string {
  return join(process.env.MEMMY_HOME?.trim() || join(homedir(), ".memmy"), "runtime.json");
}

After:

function defaultRuntimeConfigPath(env: NodeJS.ProcessEnv): string {
  return join(env.MEMMY_HOME?.trim() || join(homedir(), ".memmy"), "runtime.json");
}

71. App/backend/src/index.ts (L171-L172)

This one-off data migration is awaited inside the main bootstrap try, with no local error handling. migrateLegacyCollabInstructions → collabInstructions.remember() can reject (e.g. a legacy entry whose text exceeds MAX_COLLAB_INSTRUCTIONS_CHARS, or a failed mkdir/writeFile/rename), and projectInstructions.forget() can reject on a write error too. Any of those aborts createLocalBackend, so createLocalApiServer/listen never runs and the desktop backend is completely unavailable because of a legacy-file cleanup. Make the migration best-effort: wrap it in try/catch and only log on failure.

💡 Suggested Change

Before:

    if (legacyCloudEntries.length) {
      const moved = await migrateLegacyCollabInstructions({

After:

    if (legacyCloudEntries.length) {
      try {
        const moved = await migrateLegacyCollabInstructions({
          collabInstructions: services.collabInstructions,
          legacyEntries: legacyCloudEntries,
          forgetLegacy: (directory) => services.projectInstructions.forget(directory)
        });
        if (moved) console.log(`Moved ${moved} cloud project instruction(s) out of the local project instructions file`);
      } catch (error) {
        console.warn(`Cloud project instruction migration did not complete: ${error instanceof Error ? error.message : String(error)}`);
      }

72. App/backend/src/services/index.ts (L278-L280)

The default Memmy config path literal is duplicated verbatim for both new services and hardcodes a business file location. An existing resolver already exists (resolveDefaultMemmyConfigPath() in ../infrastructure/memmy-config/index.js), and that module is already imported in this file. Reusing it keeps the two instruction stores and the rest of the backend on one source of truth.

💡 Suggested Change

Before:

    projectInstructions: createProjectInstructionsService({
      // 与 Memmy 任务、其他 Agent 的钩子读同一份文件:配置目录下的 project-instructions.json。
      memmyConfigPath: options.memmyConfigPath ?? join(homedir(), ".memmy", "config.yaml")

After:

    projectInstructions: createProjectInstructionsService({
      // 与 Memmy 任务、其他 Agent 的钩子读同一份文件:配置目录下的 project-instructions.json。
      memmyConfigPath: options.memmyConfigPath ?? resolveDefaultMemmyConfigPath()

73. App/backend/src/services/index.ts (L293)

createCatalogAgentTaskModelResolver is only used inside this module (line 242); exporting it widens the services barrel without any external consumer. Keep it module-private unless a caller was intended.

💡 Suggested Change

Before:

export function createCatalogAgentTaskModelResolver(

After:

function createCatalogAgentTaskModelResolver(

74. App/backend/src/services/onboarding-insight-service.ts (L2406)

These helpers were changed from module-private to exported (chatCompletionsUrl, anthropicMessagesUrl, responsesUrl, openAiCompatibleThinkingControlFields, openAiCompatibleTemperatureFields), but no file outside this module references them (repo-wide search matches only definitions and in-module call sites). If they are not consumed by a new module in this change, drop the exports to avoid dead API surface.

💡 Suggested Change

Before:

export function chatCompletionsUrl(baseUrl: string): string {

After:

function chatCompletionsUrl(baseUrl: string): string {

75. App/backend/src/index.ts (L28)

createCollabCloudRequest is imported but never referenced anywhere in this file (only createCollabService is used). This is a dead import that will be flagged by unused-import/lint rules and misleads readers into thinking a raw cloud request is built here. Import only the symbol actually used.

💡 Suggested Change

Before:

import { createCollabCloudRequest, createCollabService } from "./services/collab-service.js";

After:

import { createCollabService } from "./services/collab-service.js";

76. App/backend/src/services/skill-distribution-service.ts (L172-L174)

ensureProjectSkills() writes the project skill into every agent home where the root directory exists — regardless of whether the user ever connected that agent, and without the canWriteAgentSkill / isAutoInjectSuppressed gate that agent-source-auto-inject-service.ts applies before writing skills (permission scan_only users will still get files written). It also silently undoes an explicit uninstall: uninstallSkill/uninstallPlugin → target.uninstall() removes skills/memmy-project, and the next launch recreates it because the root still exists. Consider limiting this to sources that already have a Memmy skill directory (or gating it on scan permission / suppression) so it cannot resurrect skills the user removed.


77. App/backend/src/services/skill-distribution-service.ts (L176-L177)

No per-target error isolation: resolveRootDirectory() (non-ENOENT errors are re-thrown by the targets), directoryExists() and replaceMemmyProjectSkillDirectory() can throw, which aborts the whole loop and leaves every remaining agent home without the project skill until the next launch (the caller's catch only hides it). Wrap each iteration in try/catch so one broken home does not starve the others.


78. App/backend/src/services/agent-source-service.ts (L528-L530)

Both new catch blocks swallow every error with no log/telemetry, so a persistently failing write (permissions, corrupt directory, adapter bug) keeps agents on stale instructions invisibly. The start-up caller in index.ts only logs the successful ids, so nothing surfaces the failure. Please log at least console.warn with the source id and error message, as done elsewhere in the start-up path.


79. App/backend/src/services/agent-source-service.ts (L533)

ensureProjectSkills is declared as a required member of SkillDistributionService (unlike the neighbouring optional isInstalledHookCurrent? / detectMemoryPluginConflicts?), so the optional call plus ?? [] fallback here is dead code and inconsistent with the contract; existing test doubles that implement the interface without the new methods (e.g. the satisfies SkillDistributionService literal in tests/agent-source-service.test.ts) are now stale. Either call it directly, or mark it optional in the interface to match the guard and the other optional members.


80. App/frontend/desktop/src/api/collab-client.ts (L511-L513)

projectId is silently dropped unless tasksOnly is also true — the ternary only spreads it inside the tasks: true branch. A caller that passes just projectId (which the type allows and the doc comment advertises as "opening a board never waits for the others") falls into the {} branch, which the backend treats as a full cross-project sync (syncLinkedSessions), i.e. the exact slow path the option exists to avoid. Either forward projectId on its own or make the signature require tasksOnly whenever projectId is given.

💡 Suggested Change

Before:

        body: options?.tasksOnly
          ? { tasks: true, ...(options.projectId ? { projectId: options.projectId } : {}) }
          : {},

After:

        body: {
          ...(options?.tasksOnly ? { tasks: true } : {}),
          ...(options?.projectId ? { projectId: options.projectId } : {})
        },

81. App/frontend/desktop/src/api/knowledge-bases-client.ts (L35-L38)

A bare catch { return [] } collapses every failure — 401/403 auth, 5xx, network errors and zod parse errors (requestJson throws ApiRequestError and schema errors) — into the same "no knowledge bases" result. The dialog then shows a plausible empty candidate list and the user cannot tell the lookup failed. Consider letting non-availability errors propagate (or return a discriminated result) so the dialog can show "加载失败" instead of "暂无可关联的知识库".


82. App/frontend/desktop/src/api/collab-client.ts (L443-L446)

projectId is a required member of the URLSearchParams here, so suffix can never be empty and the ${suffix ? ?${suffix} : ""} branch is unreachable. Building the path as /api/collab/sessions/candidates?${suffix} keeps the intent clear (the guard is genuinely needed in listSessions, where the params may be empty).

💡 Suggested Change

Before:

      const suffix = query.toString();
      const result = await requestJson({
        config,
        path: `/api/collab/sessions/candidates${suffix ? `?${suffix}` : ""}`,

After:

      const suffix = query.toString();
      const result = await requestJson({
        config,
        path: `/api/collab/sessions/candidates?${suffix}`,

83. App/frontend/desktop/src/app/collab-projects-store.ts (L168-L170)

addCollabProject seeds from snapshot.projects ?? [], so calling it while the first load is still running (creating/joining a project right after sign-in) turns projects === null ("not loaded yet") into a one-item list. The documented invariant in the snapshot comment (loading/null until the first load finishes) is then broken, and if the in-flight load fails the sidebar stays on that single project instead of showing "cloud unavailable". Keep the not-loaded state and let the next loadCollabProjects fill the list.

💡 Suggested Change

Before:

export function addCollabProject(project: CollabProject): void {
  const rest = (snapshot.projects ?? []).filter((current) => current.id !== project.id);
  update({ projects: [project, ...rest] });

After:

export function addCollabProject(project: CollabProject): void {
  // 列表还没读到时不要造出一份只有这一条的列表:下一次加载会用云端结果填满。
  if (!snapshot.projects) return;
  const rest = snapshot.projects.filter((current) => current.id !== project.id);
  update({ projects: [project, ...rest] });

84. App/frontend/desktop/src/app/collab-projects-store.ts (L107)

The serialization here only covers a single waiter. When several callers arrive during one run (focus + visibilitychange, the pending-application poll and a user refresh can overlap), they all await the same promise and then each starts its own run — the if (inFlight === run) inFlight = null guard does not stop that, so requests overlap and an older response can overwrite a newer list, contrary to the comment above. Loop until no load is in flight.

💡 Suggested Change

Before:

  if (inFlight) await inFlight.catch(() => undefined);

After:

  while (inFlight) await inFlight.catch(() => undefined);

85. App/frontend/desktop/src/app/open-agent-task.ts (L46)

nextAgentHistoryRequestId duplicates the identical helper defined in home-page.tsx and agent-runtime-bridge.tsx, and this exported copy is not imported anywhere (both pages keep their own). Since the module was extracted to remove duplicated history-open logic, import this helper in those pages instead of exporting yet another copy, otherwise a change to the request-id scheme has to be made in three places.


86. App/backend/src/services/skill-distribution-service.ts (L157-L158)

For kind === "plugin" the "is it current" decision only looks at the skill directory, never at the hook bundle. A hook agent is installed by target.installPlugin(), which writes the hook script, the workspace bridge and the revision field, and this build changes both templates/memmy-resume-hook.ts and workspace-bridge/runtime.ts while memmy-plugin.ts (the SKILL.md content) is unchanged. So such an agent keeps its SKILL.md matching, memoryCurrent is true, the project skill is current too, and refreshInstalledSkill returns early — the new hook/bridge never lands. agent-source-auto-inject-service.ts only repairs hooks for the UPDATABLE_HOOK_AGENT_SOURCE_IDS set (cursor/claude_code/codex/opencode), so hermes/openclaw/deepseek_harness plugin users stay on the stale hook. target.isInstalledHookCurrent is already available on the target — include it in the currency check (and in the rewrite condition) so a hook-only update triggers installPlugin.

💡 Suggested Change

Before:

      const memoryCurrent = await isMemmySkillDirectoryCurrent(root, manifest);
      if (memoryCurrent && await isMemmyProjectSkillDirectoryCurrent(root, sourceId)) return false;

After:

      const memoryCurrent = await isMemmySkillDirectoryCurrent(root, manifest);
      const hookCurrent = kind !== "plugin" || !target.isInstalledHookCurrent
        ? true
        : await target.isInstalledHookCurrent();
      if (memoryCurrent && hookCurrent && await isMemmyProjectSkillDirectoryCurrent(root, sourceId)) return false;

87. App/backend/src/services/skill-distribution-service.ts (L151)

The mode parameter has no production caller: agentSourceService.refreshInstalledSkills() always calls refreshInstalledSkill(sourceId), and the only place that passes a mode is the unit test. A hook-capable agent whose installed SKILL.md says ## Automatic Memory but whose hook is gone would then never be repaired through this path either, and the extra parameter suggests a capability that is not wired up. Either drop mode from the method and interface, or drive it from the source's real status where the call site knows better than the on-disk text.


88. App/backend/src/services/agent-source-service.ts (L109-L110)

Making refreshInstalledSkills a required member of AgentSourceService breaks the existing test double createFakeAgentSourceService(): AgentSourceService in App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts (it stops at detectMemoryPluginConflicts), so that suite no longer type-checks. Add a stub (async refreshInstalledSkills() { return []; }) there, or declare the member optional like isInstalledHookCurrent so unrelated doubles keep compiling.


89. App/frontend/desktop/src/api/memmy-agent-client.ts (L835-L839)

reassignProjectSessions is declared and implemented here (and the matching backend route exists), but nothing in the desktop app calls it — a search for reassign across App/frontend/desktop/src only matches this file. Its own doc comment says it is "used when a shared project is deleted so its sessions continue as local tasks", so either the delete flow was never wired up (leaving the sessions of a deleted shared project unreachable) or this is dead API surface that should be removed until it is needed.


90. App/frontend/desktop/src/app/collab-projects-store.ts (L192-L193)

markCollabProjectBound bails out when the list has not loaded yet (snapshot.projects === null). In app-frame this is called right after a project is created/joined and bound — which can easily happen while the first loadCollabProjects is still in flight (sign in → create). The binding is then silently dropped, so the sidebar cannot list that project's sessions until a later full load. Unlike the confirmed addCollabProject case, nothing recreates the record here, so the just-bound local project simply looks unbound. Consider buffering the binding (or writing pending bindings) and applying it when the list arrives, instead of returning early.


91. App/frontend/desktop/src/app/collab-projects-store.ts (L235-L236)

resetCollabProjectsStore only clears the inFlight reference; a load that is already running keeps its captured run closure and will still call update(...) when it resolves, writing projects/applications into the freshly reset snapshot. That breaks the "returns the store to its first state" contract for tests that reset while a load is pending (e.g. a component unmounts mid-load). Consider ignoring results after a reset, e.g. bump a generation counter in update/loadCollabProjects and drop updates whose generation no longer matches.


92. App/frontend/desktop/src/pages/collab-page.module.css (L360-L363)

.groupToggle is declared twice in this file (here and again near .groupRow around line 1332). The only consumer is the picker's group toggle (collab-page.tsx:2619), and the later block wins for every overlapping property (display, gap, padding, color), so this whole first block is dead code except margin/border-radius/letter-spacing; the .groupToggle:hover rule below still overrides the picker's muted --collab-ter colour to --collab-ink-2 on hover. Editing this block silently has no effect and the two intended contexts can no longer be styled independently — please delete this copy or rename one of them (e.g. pickerGroupToggle).


93. App/frontend/desktop/src/pages/collab-page.module.css (L1623-L1626)

--collab-ink-1 is never defined: the palette only declares --collab-ink and --collab-ink-2, and no fallback is given here. The declaration is therefore invalid at computed-value time and the <b> heading of the “stop sharing” block falls back to the inherited body colour instead of the intended darker ink (unlike .stopSharing p, which uses --collab-ink-2). Likely a typo for --collab-ink-2 (or --collab-ink).

💡 Suggested Change

Before:

.stopSharing b {
  display: block;
  margin-bottom: 4px;
  color: var(--collab-ink-1);

After:

.stopSharing b {
  display: block;
  margin-bottom: 4px;
  color: var(--collab-ink-2);

94. App/frontend/desktop/src/pages/collab-page.module.css (L1183-L1184)

--collab-surface-2 and --collab-sec do not exist anywhere in this stylesheet or the global palette, so these two declarations always fall back to the hardcoded #fafafa/#666 and the notice never follows the board palette (every other rule in the file uses --collab-surface / --collab-muted / --collab-ter). Please use the existing tokens so the notice stays consistent when the theme changes.

💡 Suggested Change

Before:

  background: var(--collab-surface-2, #fafafa);
  color: var(--collab-sec, #666);

After:

  background: var(--collab-surface);
  color: var(--collab-muted);

95. App/frontend/desktop/src/pages/collab-page.module.css (L487-L490)

.menu is split into two consecutive blocks for the same selector (top/right live here, everything else in the block just above). Two adjacent blocks with an identical selector are easy to edit in the wrong copy; please merge the positioning declarations into the first .menu rule.


96. App/frontend/desktop/src/pages/collab-page.tsx (L2724-L2727)

messageOf swallows any message that starts with [/{ and returns "", but the load/refresh/open/delete paths call showToast(messageOf(cause)) without a fallback (lines ~698, 881, 949). Since the toast renders as {toast ? ... : null}, an empty string means the failure is shown to nobody. Give these call sites a translated fallback, e.g. showToast(messageOf(cause) || t("collab.cloudUnavailable"))-style text, so a schema/JSON error still produces feedback.


97. App/frontend/desktop/src/pages/collab-page.tsx (L2114-L2116)

Opening sharing here requests the tour and (when taskCount > 0) the task picker. Both flags are consumed by two separate effects in CollabBoard in the same commit, so a first-time user gets the full-window tour overlay (z-index: 90) on top of the picker modal at once — the tour's anchors (associate button etc.) sit behind the modal backdrop, which is exactly the "高亮会落空" situation the tour effect comment warns about, and the picker is unreachable until the tour ends. The onReplayTour handler already closes the settings dialog for this reason; the picker should be deferred until the tour finishes (or skipped when !readCollabTourSeen()).


98. App/frontend/desktop/src/pages/collab-page.tsx (L2308-L2309)

This effect depends on props.onClose, but the parent passes an inline onClose={() => setReading(null)} (line ~1590). Every CollabBoard re-render (each 5s board poll that changes data, each toast) recreates that callback, so the listener is torn down/re-added and panelRef.current?.focus() runs again — pulling focus out of the panel/away from assistive tech while the user is reading. Key the effect on open only and read the close callback from a ref, or memoize the parent callback with useCallback.


99. App/frontend/desktop/src/pages/collab-page.tsx (L2199-L2201)

Nested ternaries violate the project rule and are repeated across the file: this settings description, taskStats === null ? … : taskStats.total === 0 ? … : … a few lines below, the sessions render chain (sessions === null ? … : sessions.length === 0 && filtering ? … : sessions.length === 0 ? … : …), the pager, and the picker footer. Extract each chain into a small render helper or a precomputed string, e.g. const shareHint = ownedByOther ? t("collab.settings.memberHint") : sharedId ? t("collab.settings.shareOnHint") : t("collab.settings.shareOffHint"); computed above the JSX (or a shareHint() function when it grows further).


100. App/frontend/desktop/src/pages/collab-page.tsx (L1)

Dead imports: Plus (lucide-react) and useOptionalAgentRuntimeBridge (line ~51) are never referenced in this file. Remove them; UserPlus/MessageSquarePlus are the icons actually used.

💡 Suggested Change

Before:

import { AtSign, BookOpen, ChevronDown, ChevronRight, Cloud, Folder, Layers, Link2, ListChecks, MessageSquarePlus, MoreHorizontal, PanelRight, PanelRightClose, Pencil, Plus, RefreshCw, Search, Settings, Trash2, UserPlus, X } from "lucide-react";

After:

import { AtSign, BookOpen, ChevronDown, ChevronRight, Cloud, Folder, Layers, Link2, ListChecks, MessageSquarePlus, MoreHorizontal, PanelRight, PanelRightClose, Pencil, RefreshCw, Search, Settings, Trash2, UserPlus, X } from "lucide-react";

101. App/frontend/desktop/src/pages/collab-page.tsx (L382-L383)

Writing a ref during render (agentRef.current = … here and refreshAfterUploadRef.current = refreshAfterUpload below) is a render-phase side effect React's docs forbid; under StrictMode double-render or a discarded concurrent render the ref can end up holding a value from a render that never committed. Move both assignments into a useEffect without deps (runs after every commit), which gives the same "latest value" semantics without mutating during render.

💡 Suggested Change

Before:

  const agentRef = useRef(appState?.state.agent ?? null);
  agentRef.current = appState?.state.agent ?? null;

After:

  const agentRef = useRef(appState?.state.agent ?? null);
  useEffect(() => {
    agentRef.current = appState?.state.agent ?? null;
  });

102. App/frontend/desktop/src/pages/collab-page.tsx (L974-L980)

The deletions are independent (a plain idempotent DELETE per session), but this loop awaits them one at a time: removing a full page of 10 costs 10 sequential round-trips and the button looks stuck meanwhile. Run them in parallel and count the rejections with allSettled.

💡 Suggested Change

Before:

    for (const id of ids) {
      try {
        await collab.removeSession(sharedId, id);
      } catch {
        failed += 1;
      }
    }

After:

    const results = await Promise.allSettled(ids.map((id) => collab.removeSession(sharedId, id)));
    const failed = results.filter((result) => result.status === "rejected").length;

103. App/frontend/desktop/src/pages/collab-page.tsx (L689-L694)

The same listSessions options object is rebuilt in four places (this load effect, refreshAfterUpload, refreshSessions, and the polling tick), each with its own ...(query ? { query } : {}) / ...(member === "all" ? {} : { member }) spread. A single useMemo'd sessionQueryOptions ({ query, member, offset, limit }) would keep offset/limit and the me/others mapping in one spot instead of four that can silently drift apart.


104. App/frontend/desktop/src/pages/collab-share-toggle.tsx (L44)

The draft intent is read as a non-consuming value and then OR-ed over the server truth, and nothing ever consumes it on this path. home-page writes setShareIntent(chatId, true) when the first message creates the session and only clears it if the upload fails; on success the entry stays in the in-memory map forever. So every later mount of this task computes pending === true and forces shared to true regardless of what taskSharing() returned — e.g. after the task is removed from the project via the task picker, the switch still claims "Shared with members". Consume the intent instead (takeShareIntent) so it only overrides the state until the cloud has been asked once.

💡 Suggested Change

Before:

    const pending = shareIntentFor(scopeKey);

After:

    // 消费掉:会话建立后以本机记录为准,别让草稿期的一次性意图覆盖之后读到的状态。
    const pending = takeShareIntent(scopeKey);

105. App/frontend/desktop/src/pages/collab-share-toggle.tsx (L88-L92)

Nested ternary, which the project's style rules disallow. Split the fallback out into its own value.

💡 Suggested Change

Before:

  const hint = shared
    ? t("collab.share.hintOn")
    : conversationId
      ? t("collab.share.hintOff")
      : t("collab.share.hintDraft");

After:

  const hintOff = conversationId ? t("collab.share.hintOff") : t("collab.share.hintDraft");
  const hint = shared ? t("collab.share.hintOn") : hintOff;

106. App/frontend/desktop/src/pages/collab-share-toggle.tsx (L41)

collab is part of the declared props (CollabClient | null), but when it is null and a conversationId exists this early return leaves loaded false, so the switch stays permanently disabled and just looks broken. Treat it as loaded so the person can at least retry.

💡 Suggested Change

Before:

    if (!collab) return;

After:

    if (!collab) {
      setLoaded(true);
      return;
    }

107. App/frontend/desktop/src/pages/collab-session-mention.tsx (L70-L73)

When the mention is dismissed, the effect clears candidates but does not invalidate the in-flight request: a response started before the dismissal still satisfies current === requestId.current and writes into state after the menu closed. It is only masked today by the enabled ? candidates : [] return, so the guard is not actually complete. Bump the counter in the disabled branch.

💡 Suggested Change

Before:

    if (!client || !enabled || !projectId) {
      setCandidates([]);
      return;
    }

After:

    if (!client || !enabled || !projectId) {
      // Invalidate any in-flight request so it cannot land after the menu closed.
      requestId.current += 1;
      setCandidates([]);
      return;
    }

108. App/frontend/desktop/src/pages/collab-session-mention.tsx (L135)

Static inline style that never depends on props or state; per the style guide only dynamic values belong in style. Move the max-height into a class (e.g. a collab-mention-list rule next to the other menu styles).


109. App/frontend/desktop/src/pages/collab-page.module.css (L464-L466)

.stream .rowSelecting .meta cannot win over .stream .row:hover .meta (the rule just above), because the hover rule has higher specificity (.stream .row:hover .meta = 4 selectors vs .stream .rowSelecting .meta = 3). In selection mode the “more” button is not rendered (selecting ? null in collab-page.tsx:1340), so hovering a row still hides the member name + timestamp and leaves an empty slot instead of the intended “time stays visible” behaviour described in the comment. Bump the specificity so the override actually applies on hover, e.g.:

💡 Suggested Change

Before:

.stream .rowSelecting .meta {
  opacity: 1;
}

After:

.stream .row.rowSelecting .meta,
.stream .row.rowSelecting:hover .meta {
  opacity: 1;
}

110. App/frontend/desktop/src/pages/collab-session-mention.tsx (L45)

end only reaches the end of what was typed before the caret. mentionQueryFromInput deliberately opens the menu when the caret sits inside a token (see the existing tests: mentionQueryFromInput("@智能家居 补充说明", 3) → query: "智能"), so picking a session then leaves the rest of the token behind as orphan text. With the real caller (home-page passes reference = "" because the chip carries it) "看下 @智能家居 补充说明" becomes "看下 家居 补充说明" — a stray fragment plus a doubled space. Consume the remainder of the current word so a mid-token selection replaces the whole token.

💡 Suggested Change

Before:

  const end = mention.start + 1 + mention.query.length;

After:

  const afterCaret = mention.start + 1 + mention.query.length;
  // The caret may sit inside the token: consume the rest of the word so no fragment is left behind.
  const wordRemainder = /^\S*/u.exec(input.slice(afterCaret))?.[0].length ?? 0;
  const end = afterCaret + wordRemainder;

111. App/frontend/desktop/src/pages/collab-session-mention.tsx (L82-L84)

A failed request is turned into an empty candidate list, which the page renders with home.mention.empty (“「{project}」还没有可以引用的任务”). A network/backend error therefore tells the person the project has no sessions, and there is no way to retry — the debounce only re-fires when the query changes. Track the failure (e.g. a failed flag returned next to the sessions) so the menu can say the lookup failed instead of claiming the project is empty.


112. App/frontend/desktop/src/pages/collab-page.tsx (L1305-L1306)

The session row is a plain <li> with an onClick and no role/tabIndex/onKeyDown, so a session can only be opened with a mouse — keyboard and screen-reader users have no way to enter the reader (the only focusable child is the 「…」 button). Same pattern in LocalProjectSessions and in the picker rows. Make the row an actual <button>/role="button" tabIndex={0} with an onKeyDown handler for Enter/Space, so the list is operable without a pointer.


113. App/frontend/desktop/src/pages/collab-page.tsx (L2032)

error stores the actual failure text (setError(messageOf(cause) || …) above), but the rendered node always prints the generic collab.instructions.saveFailed, so the value in state is only ever used as a boolean and the server's reason is never shown. Either render {error} (falling back to the generic label when it is empty) or drop the state and keep a boolean.

💡 Suggested Change

Before:

            {error ? <span className={styles.instructionsError} role="alert">{t("collab.instructions.saveFailed")}</span> : null}

After:

            {error ? <span className={styles.instructionsError} role="alert">{error}</span> : null}

114. App/frontend/desktop/src/pages/collab-page.tsx (L877-L879)

Closing the reader while the detail request is still in flight does not cancel it: setReadingId(null) happens in finally, and when the request resolves setReading(...) opens the panel again on top of the board after the user already dismissed it. Track the request (e.g. keep the in-flight session id in a ref/AbortController and skip setReading when the panel was closed or another row was opened meanwhile).


115. App/frontend/desktop/src/pages/collab-tour.tsx (L54-L55)

window.localStorage is touched unguarded. Accessing the localStorage getter itself can throw SecurityError (storage/cookies blocked, sandboxed frame), and setItem throws QuotaExceededError in private mode / when the quota is full. readCollabTourSeen is called inside an effect in collab-page.tsx (line 540), so a throw aborts that effect; more importantly writeCollabTourSeen is called from finishTour before setTourStep(null), so a throw there means Skip / Esc / 开始使用 silently do nothing and the tour can never be dismissed. Wrap both accesses in try/catch (return false on read, swallow on write) so a blocked storage only degrades to "tour shows again" instead of a stuck overlay.


116. App/frontend/desktop/src/pages/collab-tour.tsx (L312-L313)

This measuring effect never runs for the first step. On the initial render layout is null, so the component returns null before the bubble is mounted and bubbleRef.current is null here — measure() is skipped. The sibling layout effect then sets layout, but index is still 0, so this effect is not re-run and step 1 keeps the hardcoded guess { width: 288|344, height: 220 }. The actual bubble height (2–3 lines of body text, optional chat/compose illustration, footer) differs, so the first bubble can be pushed to the wrong top (clamped against a wrong height) and its arrow offset is computed from a stale size — while every later step measures correctly. Add a dependency that flips when the bubble mounts, e.g. [index, layout !== null].

💡 Suggested Change

Before:

    return () => observer.disconnect();
  }, [index]);

After:

    return () => observer.disconnect();
  }, [index, layout !== null]);

117. App/frontend/desktop/src/pages/collab-tour.tsx (L348-L349)

Highlight geometry is only recomputed on window resize (and on step/size changes). getAnchorRect returns viewport-relative getBoundingClientRect() values, so any move of the anchor without a window resize leaves the mask holes and the bubble pointing at stale coordinates — e.g. the board's own ResizeObserver auto-toggling configOpen (which reflows the toolbar), rows arriving after the first paint, sidebar expansion, or inner-container scrolling (the panel itself has a scroll container). The existing product tour re-measures its anchors when content changes; here nothing triggers compute(). Consider also recomputing on scroll (capture) and/or observing the anchor elements with ResizeObserver, or re-running compute on a short rAF loop while the tour is open.


118. App/frontend/desktop/src/pages/collab-tour.tsx (L370-L372)

Nested ternaries are prohibited by the project's style rules, and this four-level chain (the rawLeft chain in resolveBubbleBox has three levels as well) is hard to read and error-prone when another arrow side is added. Replace with a lookup map, e.g. const arrowClass = { top: styles.arrowTop, bottom: styles.arrowBottom, left: styles.arrowLeft }[layout.bubble.arrow] ?? null; and use a small resolveAlign helper in resolveBubbleBox.


119. App/frontend/desktop/src/pages/collab-tour.tsx (L417-L418)

The bubble announces itself as a modal dialog but no focus is moved into it and there is no focus trap, so keyboard/screen-reader users can tab straight into the board controls hidden behind the mask (the layer only blocks pointer events). Either move focus to the bubble on step change (and restore it on close) with a Tab trap / inert on the board behind, or drop aria-modal="true" so assistive tech does not claim modality that is not enforced. Related: the sample rows render <button ... tabIndex={-1} aria-hidden="true">, i.e. hiding a natively focusable element with aria-hidden — use a non-interactive element (span) for those decorative controls instead.


120. App/frontend/desktop/src/pages/collab-tour.tsx (L363-L365)

Depending on the whole props object makes this subscription re-attach on every parent render, because props is a fresh object literal each time (collab-page.tsx passes inline arrow functions for onNext/onBack). Depend on the single callback actually used (props.onSkip) via destructuring, so the listener is registered once per callback identity instead of per render.

💡 Suggested Change

Before:

    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [props]);

After:

    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [onSkip]);

121. App/frontend/desktop/src/pages/collab-tour.tsx (L422)

Resetting nudging relies on onAnimationEnd firing with a name containing tourNudge. This is brittle: collab-tour.module.css disables all bubble animations under prefers-reduced-motion: reduce, so the event never fires and nudging stays true forever (subsequent mask clicks no longer re-trigger feedback and later steps render with the nudge class). Reset the flag with a timeout fallback (or on the next click / step change) instead of depending solely on the animation event.


122. App/frontend/desktop/src/pages/collab-create-project-dialog.tsx (L1)

Cloud and Folder are imported from lucide-react but never used in this file's JSX — dead imports that will fail no-unused-vars/build lint checks.

💡 Suggested Change

Before:

import { Cloud, Folder, X } from "lucide-react";

After:

import { X } from "lucide-react";

123. App/frontend/desktop/src/pages/collab-create-project-dialog.tsx (L215)

The input allows 40 characters while validation caps the name at MAX_PROJECT_NAME_CHARS (20). Between 21 and 40 characters valid silently becomes false, so the submit button is disabled with no explanation, and the constant is duplicated by a magic number. Use the constant as maxLength (and/or surface a message when the limit is exceeded) so the UI constraint and the validation cannot drift apart.

💡 Suggested Change

Before:

              maxLength={40}

After:

              maxLength={MAX_PROJECT_NAME_CHARS}

124. App/frontend/desktop/src/pages/collab-members-dialog.tsx (L58)

acting.current is mutated during render. Writing refs in the render body is a render-phase side effect and is unsafe under concurrent rendering / StrictMode double invocation. Move it into an effect keyed on busy (or set it inside run() before/after the request).

💡 Suggested Change

Before:

  acting.current = busy !== null;

After:

  useEffect(() => {
    acting.current = busy !== null;
  }, [busy]);

125. App/frontend/desktop/src/pages/collab-tour.module.css (L293-L296)

These selectors never match: .row / .moreButton are declared in collab-page.module.css, so they compile to a different hash than the local .row here, while the DOM elements carry boardStyles.row / boardStyles.moreButton. Consequence: the sample row still gets the board hover styling (dead rule) and the "…" button keeps the board's opacity: 0, so the menu used by the "continue" tour step has no visible trigger. Target the sample row via its data-tour-anchor attribute instead of the foreign class.

💡 Suggested Change

Before:

.samples .row,
.samples .row:hover {
  background: none;
}

After:

.samples [data-tour-anchor],
.samples [data-tour-anchor]:hover {
  background: none;
}

126. App/frontend/desktop/src/pages/collab-tour.module.css (L298-L301)

Same class-scoping problem: .samples .moreButton cannot override the board module's .moreButton (different hash), so the demo row's "…" button stays invisible (opacity: 0 in collab-page.module.css). Use an attribute/element selector scoped under .samples instead.

💡 Suggested Change

Before:

.samples .moreButton {
  visibility: visible;
  opacity: 1;
}

After:

.samples [data-tour-anchor] button {
  visibility: visible;
  opacity: 1;
}

127. App/frontend/desktop/src/pages/collab-join-project-dialog.tsx (L80-L84)

messageOf() returns an empty string for JSON-shaped errors, and this catch stores that empty string directly. Since the alert is rendered as {error ? … : null}, a failed preview/join then shows no feedback at all — the click looks like a no-op. Fall back to a localized generic message when messageOf yields nothing (the create dialog already does messageOf(cause) || t(...)).


128. App/frontend/desktop/src/pages/collab-join-project-dialog.tsx (L117-L125)

primaryLabel is a four-level nested ternary, which this codebase's rules disallow and which is hard to follow. Extract a small primaryLabelFor(preview) helper (or a lookup on status/joinMode) that returns the label key.


129. App/frontend/desktop/src/pages/collab-knowledge-dialog.tsx (L141-L143)

The results area is a nested ternary (options === null ? … : visible.length === 0 ? … : …), which the review rules disallow and which mixes loading/empty/list states in one expression. Extract it into a renderResults() method (or early-return helpers) so each state is a separate, readable branch.


130. App/frontend/desktop/src/i18n/messages.ts (L166)

Placeholder syntax is wrong: formatMessage only substitutes single-brace tokens (result.replaceAll('{' + key + '}'), messages.ts:5101-5108), so {{message}} becomes {<value>} and the banner (collab-page.tsx:1040 passes { message }) renders with stray braces. Every other message in this file uses single braces. Same problem in the en-US entry at line ~2703.

💡 Suggested Change

Before:

  "collab.cloudUnavailable": "云端暂时不可用({{message}})。共享内容暂时无法读取,本机内容不受影响。",

After:

  "collab.cloudUnavailable": "云端暂时不可用({message})。共享内容暂时无法读取,本机内容不受影响。",

131. App/frontend/desktop/src/pages/app-frame.tsx (L2771-L2772)

The rename <input> (SidebarInlineRenameInput) is rendered inside this <button>: interactive content nested in a button is invalid HTML and the button's activation can swallow/duplicate clicks (the input only guards with onClick stopPropagation, and Enter/Space activation is browser-dependent). Render the row as a plain div/span while props.renaming is true (or move the input outside the button) so the rename field is never a descendant of a button.


132. App/frontend/desktop/src/pages/app-frame.tsx (L506-L507)

The guard key is recorded before updateProject resolves, so a failed/rolled-back rename is never retried for the rest of the session and the local project keeps a name that differs from the cloud. Record the key only after the update succeeds (and remove it on failure).

💡 Suggested Change

Before:

      renamedProjects.current.add(`${shared.id}:${shared.name}`);
      void updateProject(local.id, { name: shared.name });

After:

      renamedProjects.current.add(`${shared.id}:${shared.name}`);
      void updateProject(local.id, { name: shared.name }).catch(() => {
        // Allow the next snapshot to retry the sync.
        renamedProjects.current.delete(`${shared.id}:${shared.name}`);
      });

133. App/frontend/desktop/src/pages/app-frame.tsx (L388-L389)

This async IIFE has no cancellation: after unmount/sign-out it still calls mutateProject/bindLocalProject, mutates the store via markCollabProjectBound and can dispatch navigation through openProjectBoard, which is also missing from the dependency array (stale closure). Add an aborted flag cleared in the effect cleanup and bail out after each await; include openProjectBoard in the deps or wrap it in useCallback.


134. App/frontend/desktop/src/pages/app-frame.tsx (L135)

ProjectSidebarNode.shared is added but never set or read anywhere (the actual badge logic uses sharedLocalProjectIds), so this field is dead code — drop it or wire it up.


135. App/frontend/desktop/src/pages/app-frame.tsx (L3133)

sameWorkspacePath has no production caller (only tests/app-frame.test.tsx imports it), so it is dead code in this module; the doc comment also promises symlinked-home handling that the implementation does not do (it only normalises slashes/trailing separators). Either move it next to its intended consumer or remove it and fix the comment.


136. App/frontend/desktop/src/pages/collab-tour.tsx (L71)

This array and the literal list returned by createCollabTourSteps (line ~103) are two independent ordered definitions, so the "看板与引导层共用这一份" claim in the comment is not actually enforced: CollabTourOverlay derives current from steps, while collabTourShowsSharedSample / CollabTourTaskList derive the key from this array. Inserting or reordering a step in only one of them silently desynchronises the sample rows from the highlight (e.g. moving share earlier). Also note the index-clamping expression is duplicated here and in CollabTourTaskList.

Suggest a single source of order (ordered key array + a keyed record of step content) and one shared clamp helper, e.g.:

💡 Suggested Change

Before:

export const COLLAB_TOUR_STEP_KEYS: CollabTourStepKey[] = ["intro", "invite", "associate", "share", "continue", "newTask"];

After:

export const COLLAB_TOUR_STEP_KEYS = ["intro", "invite", "associate", "share", "continue", "newTask"] as const satisfies readonly CollabTourStepKey[];

/** 夹取越界的步骤索引并返回对应 key:看板与引导层共用同一套规则。 */
export function collabTourStepKey(step: number): CollabTourStepKey {
  return COLLAB_TOUR_STEP_KEYS[Math.min(Math.max(step, 0), COLLAB_TOUR_STEP_KEYS.length - 1)]!;
}

137. App/frontend/desktop/src/pages/collab-tour.tsx (L415)

Toggling styles.bubbleNudge swaps the bubble's own animation (.bubble is animation: tourEnter .24s, .bubbleNudge is animation: tourNudge .36s). Whenever the animation-name changes the browser restarts the animation, so as soon as onAnimationEnd sets nudging back to false the bubble replays tourEnter and fades in from opacity: 0 / translateY(-8px) again — every backdrop click makes the bubble blink. Additionally nudging is not reset when the step changes (the bubble is remounted through key={current.key}, but the state lives in this component), so clicking 「下一步」 before the nudge finishes mounts the new bubble with bubbleNudge applied and the entrance animation is suppressed.

Suggest applying the shake to an inner wrapper of the bubble content (keeping tourEnter on .bubble) and clearing nudging when props.step changes.


138. App/frontend/desktop/src/pages/collab-knowledge-dialog.tsx (L49)

The effect keys on the config object identity. In the caller (collab-page.tsx) runtimeConfig is derived from the clients object, and that file already documents that its identity may change on every render (see the readProjectInstructions effect). Because the board re-renders while it polls, this effect will re-run on unrelated parent renders: it clears query, wipes the user's selected set and addedIds, and re-fetches the list — so a multi-selection in progress is silently lost. Depend on a stable value (e.g. props.config?.baseUrl) like the existing precedent, or memoize the config passed down.

💡 Suggested Change

Before:

  }, [props.open, props.config]);

After:

    // eslint-disable-next-line react-hooks/exhaustive-deps -- config 的身份每渲染都可能变化,改看它的地址
  }, [props.open, props.config?.baseUrl]);

139. App/frontend/desktop/src/pages/collab-members-dialog.tsx (L127-L128)

messageOf returns an empty string when the error message looks like a JSON payload (and for a non-Error without a message). onToast("") is then a no-op because the parent renders the toast only when the text is truthy, so a failed role change / join-mode change / approval gives the user no feedback at all. Fall back to a localized generic failure message, e.g. onToast(messageOf(cause) || t("collab.members.loadFailed")) (and the same in copyInvite, where messageOf can also yield "").


140. App/frontend/desktop/src/pages/collab-knowledge-dialog.tsx (L82)

This surfaces the raw cause.message in the alert, while the sibling dialogs sanitize with messageOf (which drops JSON-shaped payloads) before displaying. A backend error whose message starts with {/[ would dump a JSON body into the UI here. Reuse the same sanitizer with the existing t("collab.knowledge.addFailed") fallback for consistency.

💡 Suggested Change

Before:

      setError(cause instanceof Error ? cause.message : t("collab.knowledge.addFailed"));

After:

      setError(messageOf(cause) || t("collab.knowledge.addFailed"));

141. App/frontend/desktop/src/pages/app-frame.tsx (L1143)

The new onlineProjectId parameter is dead plumbing: no call site ever passes it (ProjectTreeSection calls props.onProjectContextMenu(event, node.project.id) with two arguments, and openProjectMenu is passed directly as that callback), and ProjectContextMenuState has no onlineProjectId field, so the two ...(onlineProjectId ? { onlineProjectId } : {}) spreads write a property nothing reads. The menu already resolves the cloud project through sharedProjectFor(collabSnapshot.projects, projectContextMenu.projectId). Drop the parameter and the spreads to avoid a second, unused source of truth.


142. App/frontend/desktop/src/pages/app-frame.tsx (L3404)

onProjectMembers is declared as a prop but never read inside ProjectContextMenu and never passed by the caller, so it is dead code. Remove it (or wire up the members entry) to keep the menu's contract honest.


143. App/frontend/desktop/src/state/agent-composer-state.ts (L65-L66)

The "empty draft" check ignores pending @-mentions. A draft that only carries a reference chip (no text/attachments) is treated as empty, so when the user switches to an existing session and then clicks "New task", newChatRequested bumps newChatRequestId and the mention stored under the old draft-* scope is silently dropped from the composer. Since mentions are unsent composer input too, add composerMentionsByScope to NewAgentDraftState and include it in the check (same for the collab "continue task" flow that seeds a mention).

💡 Suggested Change

Before:

  return !agent.composerDraftsByScope[draftScopeKey]
    && !(agent.composerPendingAttachmentsByScope[draftScopeKey]?.length);

After:

  return !agent.composerDraftsByScope[draftScopeKey]
    && !(agent.composerPendingAttachmentsByScope[draftScopeKey]?.length)
    && !(agent.composerMentionsByScope[draftScopeKey]?.length);

144. App/frontend/desktop/src/state/agent-composer-state.ts (L57)

The doc comment states the opposite of the implementation: it says a new blank draft is opened when there is unsent content and the current blank draft is reused otherwise, but the code returns true (create a new draft) precisely when the current draft scope has no text/attachments. Either the condition or the comment is wrong; please align them so the intended UX is unambiguous (the accompanying test in app-frame.test.tsx asserts the current code behavior, so the comment looks stale).


145. App/frontend/desktop/src/pages/agent-thread-messages.tsx (L3308-L3309)

This drops the string normalisation that used to run here: trimming, collapsing \n{3,} runs, and pretty-printing stringified JSON. Tool arguments/results are frequently JSON strings (see appendToolDetailRow(rows, "Arguments", rawArguments)), so they now render as a single compact, unformatted line and long results keep their repeated blank lines. Please keep the JSON/whitespace handling for string payloads and only skip it where a pre-formatted display_text is passed explicitly (e.g. by branching on the caller instead of removing it here).


146. App/frontend/desktop/src/pages/agent-thread-messages.tsx (L2577)

The saved-output button only renders from inside a Result detail row, and toolDetailRows/appendToolDetailRow drop that row whenever event.display_text ?? event.result is null/empty (empty array/object/whitespace string). So a tool event that carries output_file but an empty or missing result loses the file link entirely — the user gets no way to open the persisted output even though the metadata is there. The file row is derived from event.output_file, not from the text value, so it should not be gated on the text being non-empty (e.g. build the details entry from output_file directly, or special-case the section before the detail.value.trim() filter).


147. App/frontend/desktop/src/pages/agent-thread-panel-preview.tsx (L441)

For a file whose size is <= THREAD_TEXT_PREVIEW_MAX_BYTES but larger than TEXT_PAGE_BYTES, the first ranged request succeeds (end < total), so control falls through to fetchBlob(mediaUrl, ...) below and the whole file is downloaded a second time just to re-apply the same size check. Consider reusing the already-fetched page / continuing the range reads, or extracting the size-limit handling into one helper so the threshold is defined once.


148. App/frontend/desktop/src/pages/composer-inline-editor.tsx (L170-L171)

chipsRef is initialised and reassigned on every render but never read anywhere in this file — dead state (and a ref mutation during render). Remove it, or use it instead of the closure value if it was meant to give chipElement/onRemoveChip access to the latest chips.

💡 Suggested Change

Before:

  const chipsRef = useRef(props.chips);
  chipsRef.current = props.chips;

After:

  // (chipsRef removed: unused)

149. App/frontend/desktop/src/pages/composer-inline-editor.tsx (L119-L122)

When the editor has no trailing text node (empty message, or only chips present), the fallback collapses the range to the start of the editor, i.e. before the chips. Restoring a caret at text offset 0 then lets the next keystroke land ahead of the mention chips, so the message text no longer follows the chips the user sees. Handle the empty case by appending an empty text node after the chips (or collapsing to the end of the last chip's parent) before positioning the caret.

💡 Suggested Change

Before:

  } else {
    range.selectNodeContents(root);
  }
  range.collapse(true);

After:

  } else {
    const text = document.createTextNode("");
    root.append(text);
    range.setStart(text, 0);
  }
  range.collapse(true);

150. App/frontend/desktop/src/pages/composer-inline-editor.tsx (L232-L233)

onPaste is optional, so a call site that does not pass it lets rich HTML be inserted directly into this contentEditable. readEditorPlainText turns the resulting <div>/<p>/<br> structure into stray newlines (and drops <img>), which breaks the "chips + one text node" invariant this component's rebuild logic relies on and can send mangled text to the agent. Consider normalising pastes inside the component (preventDefault + insertText from event.clipboardData.getData("text/plain")) so every caller gets plain text.

💡 Suggested Change

Before:

      onKeyDown={props.onKeyDown}
      onPaste={props.onPaste}

After:

      onKeyDown={props.onKeyDown}
      onPaste={(event) => {
        // Added-comment: keep the editor plain-text only even when no caller handler is supplied.
        if (props.onPaste) {
          props.onPaste(event);
          return;
        }
        event.preventDefault();
        document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
      }}

151. App/frontend/desktop/src/pages/home-page.tsx (L2770-L2771)

selectMentionSession is async, but everything after await collabClient?.readReference(...) still uses the render-time closure: composerInput, chatScopeKey and state.agent.composerMentionsByScope. If the person keeps typing during the download, updateComposerInput(next) writes the old string back into the draft (characters typed meanwhile are lost). If they switch task during the download, the chip is dispatched to the captured scopeKey while updateComposerInput writes to the now-current scope, i.e. one task's chip and another task's text. Re-read the latest draft from composerDraftsRef.current[scopeKey] after the await (and bail out when the scope changed) instead of applying applyMention to the stale value.

💡 Suggested Change

Before:

    const next = applyMention(composerInput, mention, "");
    updateComposerInput(next);

After:

    // The download above is async: do not overwrite a draft that changed meanwhile, and never write across scopes.
    if (chatScopeKey !== scopeKey) return;
    const latestDraft = composerDraftsRef.current[scopeKey] ?? "";
    const latestMention = mentionQueryFromInput(latestDraft, latestDraft.length);
    const next = applyMention(latestDraft, latestMention ?? mention, "");
    updateComposerInput(next);

152. App/frontend/desktop/src/pages/home-page.tsx (L2005)

The deferred share of a newly created task fails silently: setTaskSharing errors are swallowed and the flag is only reset in setShareIntent, so the user sees the switch flip back with no explanation even though the whole feature has a collab.share.failed message ready (the CollabShareToggle dispatches it). Related: takeShareIntent(sendScopeKey) is destructive and is consumed before checking online/collabClient, so when the collab store has not loaded yet (projects === null) or the project is not mapped, the user's choice to share is dropped without any notice. Surface the failure through agentActions.operationFailed like the toggle does.

💡 Suggested Change

Before:

              void collabClient.setTaskSharing(online.id, chatId, true).catch(() => setShareIntent(chatId, false));

After:

              void collabClient.setTaskSharing(online.id, chatId, true).catch(() => {
                setShareIntent(chatId, false);
                dispatch(agentActions.operationFailed("chat", createAgentOperationError({
                  source: "send",
                  message: "collab.share.failed",
                  chatId
                })));
              });

153. App/frontend/desktop/src/pages/home-page.tsx (L1040-L1043)

The same local→online project lookup is written twice with different data sources: this memo reads getCollabProjectsSnapshot() inside the body while sharedOnlineProject below reads the already-subscribed collabProjects value for the identical projects.find(p => p.localProjectId === composerLocalProjectId) expression. collabProjects only appears in the dep list to force recomputation, which makes the memo body read from a source that is not the dependency. The store already exports sharedProjectFor(projects, localProjectId); deriving the online project once from collabProjects keeps a single source of truth for the composer.

💡 Suggested Change

Before:

  const composerOnlineProjectId = useMemo(() => {
    if (!composerLocalProjectId) return null;
    return getCollabProjectsSnapshot().projects?.find((project) => project.localProjectId === composerLocalProjectId)?.id ?? null;
  }, [composerLocalProjectId, collabProjects]);

After:

  // One lookup from the subscribed snapshot; both the @ menu and the share toggle read it.
  const sharedOnlineProject = useMemo(
    () => sharedProjectFor(collabProjects.projects, composerLocalProjectId),
    [composerLocalProjectId, collabProjects]
  );
  const composerOnlineProjectId = sharedOnlineProject?.id ?? null;

154. App/frontend/desktop/src/state/agent-composer-state.ts (L52-L53)

NewAgentDraftState widens the real slices to unknown/unknown[], so the function silently accepts any shape: a caller can pass { composerDraftsByScope: { "draft-1": 42 } } or attachments of arbitrary type and still type-check, and the ?.length probe works on non-array values at runtime. The concrete types already exist in this module (Record<string, string> and PendingAttachment[]), and AgentState stays assignable to them, so tighten the contract.

💡 Suggested Change

Before:

  composerDraftsByScope: Record<string, unknown>;
  composerPendingAttachmentsByScope: Record<string, unknown[] | undefined>;

After:

  composerDraftsByScope: Record<string, string>;
  composerPendingAttachmentsByScope: Record<string, PendingAttachment[] | undefined>;

155. App/frontend/desktop/src/state/app-actions.ts (L22)

This adds a second import statement from ./agent-chat-slice.js (there is already a multi-specifier import type { ... } from "./agent-chat-slice.js" a few lines below). Merge ComposerMention into that existing block to keep a single import per module. Also, the new action is not separated from composerPendingAttachmentsUpdated by a blank line, unlike the rest of this action map.


156. App/frontend/desktop/src/pages/agent-thread-panel-preview.tsx (L305-L306)

readTextPage only tolerates two exact shapes: a 200 (returned as a Response) or a 206 whose content-range matches ^bytes (\d+)-(\d+)\/(\d+)$. A 206 with an unknown total (bytes 0-262143/*), a lowercase header form, or a proxy that rewrites the header makes it throw. Because loadFilePreview propagates that error, ThreadTabPreview falls into its generic status: "error" state, so a file that used to preview fine via the full download now shows an error with no fallback. Consider degrading to the full-blob path when the range metadata is missing/unparseable instead of throwing.


157. App/frontend/desktop/src/pages/composer-inline-editor.tsx (L169)

chipKey is built from chip ids only, so chips are only re-rendered when the set of ids changes. When a referenced session's title changes (and chips keeps the same id), the chip keeps showing the stale truncated label until an id is added/removed. Include the labels in the key (e.g. chips.map(c => ${c.id}:${c.label}).join("\u0000")) so a renamed reference updates in place.


158. App/frontend/desktop/src/pages/home-page.tsx (L113)

MessageSquare is imported here but never used anywhere in this file (the mention chip is rendered by ComposerInlineEditor). Unused import = dead code, and it will trip noUnusedLocals/lint.

💡 Suggested Change

Before:

import { ArrowDown, Check, ChevronDown, Folder, MessageSquare, Plus as LucidePlus, RotateCw, Target, X } from "lucide-react";

After:

import { ArrowDown, Check, ChevronDown, Folder, Plus as LucidePlus, RotateCw, Target, X } from "lucide-react";

159. App/frontend/desktop/src/pages/home-page.tsx (L2751)

This function was inserted between handleComposerKeyDown's JSDoc block and the function itself, so the doc comment above ("Handles keyboard interaction in the input box … @PARAM event The textarea keyboard event.") now describes selectMentionSession and is no longer attached to handleComposerKeyDown. Please move the block back above handleComposerKeyDown and give selectMentionSession its own accurate doc (it also mentions "textarea", which is now a contentEditable div).


160. App/frontend/desktop/src/pages/home-page.tsx (L2472)

composerMentionsUpdated replaces the whole list for the scope, so restoring after a failed send drops any mention the person picked while the request was in flight. Attachments a few lines above are merged (attachments + current, de-duplicated) — mentions should follow the same pattern, e.g. restore [...mentions, ...current.filter(m => !submittedIds.has(m.id))] so nothing typed during the send is silently lost.


161. App/memmy-agent/src/entrypoints/frontend-bridge/projects.ts (L334)

The MAX_PROJECTS guard was dropped from add() while load() still treats a file with more than MAX_PROJECTS entries as corrupt (if (!Array.isArray(rawProjects) || rawProjects.length > MAX_PROJECTS) throw new Error("invalid projects file") → state corrupt), and write() performs no size check. So once a user creates the 101st project through this path, the registry is written with 101 entries and can never be loaded again: every subsequent mutation throws project_registry_corrupt (503) and the project list comes back empty — i.e. all projects look lost. resolveOrRegisterExisting still enforces the limit, so the two paths are now inconsistent. Please restore the guard (and keep returning the documented project_limit_reached 409) before writing.

💡 Suggested Change

Before:

      if (existing && !options.allowShared) return publicProject(existing);

After:

      if (existing && !options.allowShared) return publicProject(existing);
      if (this.projects.length >= MAX_PROJECTS) {
        throw new WebuiProjectError("project_limit_reached", 409);
      }

162. App/memmy-agent/src/entrypoints/frontend-bridge/projects.ts (L335)

With allowShared: true this branch always falls through to const next = [...this.projects, project], so every call for a directory that already has a project appends a brand-new record. handleProjectCreate forwards allowShared on each collab join/create request, and StoredWebuiProject carries no collab/cloud id, so nothing downstream can tell the duplicate apart from the original: repeated joins accumulate records with the same root_path (and the same auto-derived name in the project list), split sessions across them, and eventually push the file past MAX_PROJECTS (see the missing limit check above → corrupt registry). Consider passing a distinguishing key (e.g. the cloud project id) so an existing shared record for the same folder+project can be returned instead of appended.


163. App/memmy-agent/src/project-instructions/index.ts (L73)

ProjectInstructionsEntrySchema.text accepts up to MAX_PROJECT_INSTRUCTIONS_CHARS * 4 (40k chars) and only the writer clamps to 10k, so a file produced by an older version or edited by hand is injected verbatim into every turn's system prompt here. The contract already exports projectInstructionsText() for exactly this fallback (currently unreferenced anywhere, i.e. dead code) — use it instead of a raw trim().

💡 Suggested Change

Before:

      return projectInstructionsAt(load(), workspace)?.text.trim() ?? "";

After:

      return projectInstructionsText(projectInstructionsAt(load(), workspace)?.text);

164. App/memmy-agent/src/collab-instructions/index.ts (L84)

Same as the local project instructions reader: text may be up to MAX_COLLAB_INSTRUCTIONS_CHARS * 4 in the cached file (only remember() clamps on write), and this raw trim() injects all of it into the prompt on every system-prompt build. collabInstructionsText() exists in the contract for this fallback but is never used — prefer it here.

💡 Suggested Change

Before:

      const text = hit.entry.text.trim();

After:

      const text = collabInstructionsText(hit.entry.text);

165. App/memmy-agent/src/collab-instructions/index.ts (L110)

The hook is built from an anonymous class expression, so hook.constructor.name is the empty string and hookLabel() in hook.ts (which uses hook.constructor.name as the fallback) will log AgentHook.onBuildSystemPrompt error in : with no indication of which hook failed. A named class (defined once outside the factory) keeps error messages useful and also matches the existing hook style in the codebase (memmy-memory/hook.ts).

💡 Suggested Change

Before:

  return new (class extends AgentHook {

After:

  return new CollabInstructionsHook(source);

166. App/memmy-agent/src/core/agent-runtime/tools/shell.ts (L245-L247)

sessionKey is stored on the tool instance instead of in the per-request store used by every other context-aware tool (RequestContextStore in message.ts / cron.ts / browser.ts). ExecTool instances can outlive a single turn: setToolContext defaults to the shared this.tools registry, so a later CLI/cron/subagent turn that does not re-bind this instance keeps the previous websocket session key and exports a stale MEMMY_TASK_ID, making memmy-project session share act on the wrong task. Use an ALS-backed store and read it at prepare time.

💡 Suggested Change

Before:

  setContext(context: RequestContext): void {
    this.sessionKey = context.sessionKey ?? null;
  }

After:

  setContext(context: RequestContext): void {
    this.requestContext.set(context);
  }

167. App/memmy-agent/src/core/agent-runtime/runner.ts (L1212)

Detecting "the tool used formatTextResult" by string identity (preparedText?.content === raw) is fragile, and because it skips normalizeToolResult entirely it also skips empty-result normalization. A tool that formats and then appends anything (e.g. a footer) silently falls back to the old path and gets its already-bounded/prepared text re-persisted and re-truncated. Track an explicit flag instead of comparing strings.


168. App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts (L693)

The char-fragment path never calls this.fileStates().recordRead(target, ...), unlike the line path below (and unlike formatDocumentRead semantics). When a model reads a file only through char_offset/char_limit, FileStates.checkRead later reports "file has not been read yet" for edits and dedup state is never populated. Record the read here as well (the char offset can be passed as offset).


169. App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts (L679)

slice(0, budget) truncates the diagnostic itself, so with a small per-tool budget the model receives a fragment like "Error: read_file budget too sma" instead of a usable instruction (same pattern in the surrogate-pair error above). Keep the actionable part first and only shorten the tail, or return the message unbounded since it is already short.


170. App/memmy-agent/src/core/agent-runtime/tools/web.ts (L904)

The JSON branch no longer parses the body, so a broken JSON payload is now persisted as a .json file reported as Saved content complete: yes and offered to the model as structured data. Previously responseJson surfaced the parse failure as an error. Validate the body (e.g. keep responseJson for the model-facing text and persist the raw body), or mark the extraction as unvalidated so the model does not assume a parseable document.


171. App/memmy-agent/src/core/agent-runtime/loop.ts (L888)

The correctness of the collab section now depends implicitly on the relative order of these three unshift calls (project hook must run before collab so after: "project-instructions" resolves), and there is no comment/test guarding that invariant. Note also that when a workspace has collab instructions but no local project instructions, the collab hook's anchor is missing and upsertSection silently appends the section at the end of the prompt. Please make the ordering explicit (e.g. document the required sequence next to the unshift chain or assert it) and decide the intended fallback anchor for the collab section.


172. App/memmy-agent/src/integrations/channels/websocket.ts (L4438-L4441)

The range branch opens and reads the file with no error handling. dispatchHttp has no per-route try/catch, so any openSync/readSync failure (file removed between the earlier existsSync/statSync and this call, permission error, short/partial read) escapes handleMediaFetch and is turned into a generic 400 carrying the raw exception message by the top-level handler, instead of a 404/416. The stat done at line 4424 is also repeated at 4426, so the check and the read are not based on one snapshot (TOCTOU). Reuse a single statSync result and wrap the read.

const size = fs.statSync(candidate).size;
💡 Suggested Change

Before:

      const body = Buffer.alloc(end - start + 1);
      const descriptor = fs.openSync(candidate, "r");
      try { fs.readSync(descriptor, body, 0, body.length, start); }
      finally { fs.closeSync(descriptor); }

After:

      const body = Buffer.alloc(end - start + 1);
      let descriptor: number;
      try {
        descriptor = fs.openSync(candidate, "r");
      } catch {
        return httpError(404, "not found");
      }
      try { fs.readSync(descriptor, body, 0, body.length, start); }
      catch { return httpError(500, "read failed"); }
      finally { fs.closeSync(descriptor); }

173. App/memmy-agent/src/integrations/channels/websocket.ts (L4429)

Only the single-range form is accepted. Multi-range requests (bytes=0-99,200-299), a header with surrounding whitespace, or any other unsupported syntax fall through to !match and are answered with 416. RFC 7233 explicitly allows a server to ignore a Range header it does not support and return the full 200 body; answering 416 makes the media completely unfetchable for clients/players that optimistically send multi-range. Trim the header and fall back to the full response instead of 416 for unsupported syntaxes.

💡 Suggested Change

Before:

      const match = /^bytes=(\d*)-(\d*)$/.exec(range);

After:

      const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());

174. App/memmy-agent/src/integrations/channels/websocket.ts (L2477-L2482)

The reassignment is not atomic: sessionManager.save is called inside the loop with no per-session guard, so if one save throws the handler returns an error while the sessions already iterated have been re-pointed to to.id and persisted. The two projects are then left inconsistent (some sessions on from, some on to). Collect the original metadata so a failure can be rolled back, or catch per-session and report how many were moved.


175. App/memmy-agent/src/utils/helpers.ts (L631)

The header hardcodes Saved content complete: yes (and Complete: yes in the compact variant below) and ignores opts.complete. A caller that persists a partial payload with complete: false still tells the model the saved copy is the full output, which defeats the purpose of the new complete flag read back by readPersistedToolResultMetadata.

💡 Suggested Change

Before:

    let header = `${EXTERNAL_CONTENT_BANNER}\n[tool output persisted]\nFull output saved to: ${filePath}\nOriginal size: ${textPayload.length} chars\nSaved content complete: yes\n${opts.sourceDescription ?? ""}`;

After:

    let header = `${EXTERNAL_CONTENT_BANNER}\n[tool output persisted]\nFull output saved to: ${filePath}\nOriginal size: ${textPayload.length} chars\nSaved content complete: ${opts.complete === false ? "no" : "yes"}\n${opts.sourceDescription ?? ""}`;

176. App/memmy-agent/src/utils/helpers.ts (L530)

complete: true is hardcoded when delegating to persistToolResult, so every external fetch is persisted with complete: true regardless of whether the upstream text was already truncated (e.g. a byte/size cap in the fetcher, or a max_chars-limited result). The model then reads Saved content complete: yes / Complete: yes and may draw conclusions from missing data. Either take the flag from options or document that callers must only pass already-complete text.


177. App/memmy-agent/src/utils/helpers.ts (L609-L611)

With suffix === "json", only an array payload is serialized as JSON; a plain string payload (e.g. a raw response body that failed to parse, or any caller that sets suffix: "json" for text) is written verbatim into a .json file. The persisted reference/banner then tells the model to query that file "with an integer-safe parser (e.g. Python json)", which fails on such a file. Either validate that the string parses as JSON and fall back to the .txt suffix, or drop the JSON-specific guidance when the content is not a serialized structure.


178. App/memmy-agent/src/utils/helpers.ts (L527)

4000 (the UI raw-text threshold / preview cap) is repeated inline here, in the fallback branch of formatExternalToolText and in filesystem.ts, and 1500 (perEnd) plus 160 (header slack) are new unnamed limits. These budget constants should be named constants (or an exported one) so the model budget, the UI raw-text threshold and the persisted preview cannot drift apart.


179. App/memmy-agent/src/utils/progress-events.ts (L207-L208)

display_text and output_file are forwarded verbatim from the untyped event: any, while the neighbouring arguments/result fields are explicitly redacted/sanitized. An unexpected shape (non-object output_file, extremely long display_text) is then shipped straight to the frontend clients. Narrow the values here (e.g. validate output_file is an object with the expected ToolOutputFile keys and cap display_text length).


180. App/memmy-agent/src/knowledge/register.ts (L9-L10)

This spread is a no-op: it copies every field of context and re-assigns workspace to its own value. The new comment claims the working directory decides whether the collab project knowledge bases are attached, but no such transformation happens, and breaking object identity adds nothing. Either implement the intended workspace transformation or revert to await recall.beforeRun(context); and drop the misleading comment.

💡 Suggested Change

Before:

      // 工作目录决定这一轮要不要带上协作项目关联的知识库。
      await recall.beforeRun({ ...context, workspace: context.workspace });

After:

      await recall.beforeRun(context);

181. App/memmy-agent/src/collab-instructions/index.ts (L66)

Only successful parses populate cached, so a file that parses to the empty fallback (corrupt JSON, oversized text rejected by the schema, hand-edited file) is re-read and re-parsed from disk on every system-prompt build — the hook runs on each turn and there is no size guard before readFileSync. Cache the negative result under the same key so a broken/large file is read once, and bail out early on an obviously oversized file. (project-instructions/index.ts has the same pattern.)

💡 Suggested Change

Before:

      const file = parseCollabInstructionsFile(JSON.parse(fs.readFileSync(filePath, "utf8")));

After:

      const empty = emptyCollabInstructionsFile();
      try {
        const file = parseCollabInstructionsFile(JSON.parse(fs.readFileSync(filePath, "utf8")));
        cached = { key, file };
        return file;
      } catch {
        cached = { key, file: empty };
        return empty;
      }

182. Knowledge/src/client.ts (L253)

Behavior change worth confirming: with a projectId, the personal switch (settings.enabled) no longer gates the call, so the user's raw query text is still uploaded to the cloud while they have recall turned off. Previously "off" meant no /recall request at all. If the master switch is advertised as "no knowledge recall / no upload", this silently breaks that expectation for project workspaces — consider giving project-scope recall its own consent signal (or checking server-side that the project association alone is sufficient authorization).


183. Knowledge/src/client.ts (L46)

Dropping implements KnowledgeRecallClient removes the compile-time contract, and the two collaborators now share the same positional second parameter with different meanings (workspace for KnowledgeRecallClient/local client vs projectId here). The signatures are structurally identical, so implements would still compile — the removal only loses the check. A later caller typed as KnowledgeRecallClient can pass a workspace path into this method and it will be forwarded to the cloud as a project id with no type error. Suggest declaring a dedicated contract (e.g. interface KnowledgeProjectRecallClient { recall(query, projectId?, signal?) }) and implementing it here, so the semantic divergence with KnowledgeRecallClient is explicit and still type-checked.


184. Knowledge/src/local-client.ts (L43)

An older local backend rejects the extra workspace key (Object.keys(body).some((key) => key !== "query") → 400 "检索问题无效" in the previous version of routes.ts), and the generic catch below rewrites any non-OK response into 503 "知识库服务暂时不可用". In a mixed-version install (agent runtime newer than the desktop local backend) recall would fail on every turn instead of degrading to personal scope. Consider retrying once with { query } when the response status is 400, or having the route ignore unknown body fields.


185. Knowledge/src/routes.ts (L301-L303)

The lookup failure is swallowed with no log, so a broken/renamed collab binding would silently degrade every project recall to personal scope with no trace. Suggest logging the failure kind (without the workspace path) via knowledgeLog so it is diagnosable.

💡 Suggested Change

Before:

        const projectId = workspace && options.projectForWorkspace
          ? await options.projectForWorkspace(workspace).catch(() => null)
          : null;

After:

        let projectId: string | null = null;
        if (workspace && options.projectForWorkspace) {
          try {
            projectId = await options.projectForWorkspace(workspace);
          } catch {
            knowledgeLog({
              hop: "local-route",
              action: "recall",
              kind: "project-lookup",
              method: request.method,
              path: request.url,
            });
          }
        }

186. App/shell/desktop/src/main/main.ts (L1168)

installed is populated even when writeUserCliLauncher returned "kept-file" — i.e. ~/.local/bin/<name> is an unrelated regular file (no # Memmy CLI launcher marker) or a non-file entry, so nothing was written and no /usr/local/bin or Homebrew link was created. The result is then reported as a success: boot:cli-installed:... is logged in installPackagedMacCliLaunchers, and installCliTools returns ok: true with these entries to the memmy:install-cli-tools IPC caller (skipped stays empty, so no error surfaces either). Record the conflict as skipped so the caller/log tells the truth.

Suggested shape:

const wrote = await writeUserCliLauncher(target, renderMemmyCliLauncher(name, appPaths));
if (wrote === "kept-file") {
  skipped.push(`${name}:${target} already exists and is not a Memmy CLI launcher`);
  continue;
}
await installSystemBinSymlink(target, name);
await installBinDirectorySymlink(HOMEBREW_CLI_BIN_DIRECTORY, target, name);
installed.push({ name, source: target, target });

187. App/shell/desktop/src/main/main.ts (L1153)

Unlike the legacy path (await access(source) before linking) and the Windows installer (installPackagedWindowsCliTools throws when a launcher is unreadable), the macOS path never verifies that any candidate actually contains Contents/Resources/cli/<name>. A bundle shipped without one of PACKAGED_MAC_CLI_COMMANDS (or with a staging mistake, e.g. only memmy-project.js present) still gets three permanent memmy* commands on PATH, each failing at runtime with exit 127 — the real packaging failure is hidden instead of reported as skipped.

Consider checking at least one of appPaths for Contents/Resources/cli/<name> before installing that name, and pushing a skipped entry when none has it.


188. App/shell/desktop/src/main/main.ts (L1071-L1073)

This guard is unreachable in practice, and the removed isInstalledApplicationsApp() check is no longer compensated. Because macOS returns earlier and the Windows strategy returns earlier, only Linux reaches these lines, and bundledCliInstallDecision can only report disk-image/translocated for macOS bundle layouts — so decision is always "install" here. At the same time the old /Applications guard is gone, so a packaged Linux build launched from an arbitrary location (e.g. an unpacked tarball under /tmp) now writes ~/.local/bin/memmy* symlinks pointing into that temporary directory and appends the PATH export to four dotfiles, leaving dangling commands behind after the directory is removed.

Either drop the dead decision branch and keep an explicit install-location guard for non-macOS, or route Linux through installPackagedMacCliCommands-style self-resolving launchers instead of raw symlinks.


189. App/shell/desktop/src/main/main.ts (L1400)

installBinDirectorySymlink only checks W_OK on the directory, and installSymlink unconditionally unlinks whatever symlink already sits at <dir>/<name>. Applied to /usr/local/bin and /opt/homebrew/bin (both writable for many users, and /opt/homebrew/bin is Homebrew-managed), this silently steals/replaces symlinks this app does not own — for example a developer's dev-start.sh link (scripts/clear-all.sh even cleans /usr/local/bin/memmy-project) or a future formula of the same name — and drops unmanaged files into the Homebrew prefix.

Consider only replacing a link whose readlink target lives under this app's bundle / ~/.local/bin, and leaving foreign entries untouched (returning "kept-file").


190. App/shell/desktop/src/main/main.ts (L1164-L1166)

const source = target; is a no-op alias, and the results of installSystemBinSymlink / installBinDirectorySymlink are discarded, so a failure or a clobbered entry in /usr/local/bin or /opt/homebrew/bin is never logged even though the log line at the call site claims full success. Also note installed records source: target, i.e. the generated launcher rather than the app's real CLI, which is misleading for consumers of CliInstallResult (memmy:install-cli-tools).

Use target directly and log the symlink outcomes (e.g. include systemLink/homebrewLink in the boot log entry).


191. App/shell/desktop/src/main/cli-auto-install.ts (L76)

The failure text is a hardcoded, Chinese-only product string, while every other user-facing string in the main process is locale-gated (app.getLocale().startsWith('zh') ? ... : ..., see main.ts). A non-Chinese user whose Memmy.app is missing gets an unreadable memmy*: 找不到 Memmy ... error, and the message can only be changed by editing source. /Applications/Memmy.app in memmyCliAppCandidates is likewise hardcoded template text rather than a named constant shared with the rest of the file. Consider at least emitting a bilingual message (... not found / 找不到 Memmy ...) here.


192. App/shell/desktop/src/main/cli-auto-install.ts (L13)

normalized === "/Volumes" can never be true: the argument is always the running .app bundle path, never the volume root. The equality arm is dead; startsWith("/Volumes/") alone expresses the intent.


193. scripts/clear-all.sh (L15-L16)

The macOS cleanup now removes memmy-project, but the Windows counterpart (scripts/clear-all-windows.ps1) was not updated: it still enumerates only memmy, memmy-memory, memmy-agent in Discover-VerifiedGlobalCliTargets / Add-DefaultTargets, and Test-IsVerifiedCliLauncherContent only recognizes launchers containing App/memmy-agent/dist/main.js or Memory/dist/src/cli/index.js. As a result, memmy-project.cmd launchers created by NSIS/dev-start survive a Windows uninstall, leaving a dangling command pointing at a deleted app. Please add memmy-project (+ .cmd/.ps1/.bat/.exe variants) and the App/backend/dist/src/cli/memmy-project.js marker to the Windows cleanup script to keep both platforms consistent.


194. Knowledge/src/routes.ts (L296)

校验只限制了 workspace 的长度,却允许任意类型:text() 对非字符串一律返回 "",所以 { query, workspace: 123 } / { workspace: {...} } 这类调用方 bug 会被静默当作「没有工作目录」,退化成只查个人知识库而不报错——排查时很难看出项目知识库为什么没生效。建议和 projectId 那条一样严格:workspace 出现时必须是字符串,否则 400。

💡 Suggested Change

Before:

          text(body.workspace).length > 4096

After:

          (body.workspace !== undefined && typeof body.workspace !== "string") ||
          text(body.workspace).length > 4096

195. scripts/internal/win/build-nsis.sh (L799)

The added presence check does not match the assertion in App/shell/desktop/tests/packaged-runtime-boundary.test.ts (line ~1152), which expects the literal string require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/cli/memmy-project.cmd". As written the packaged-runtime-boundary test will fail. Also, unlike the memmy-memory.cmd check below, there is no ENTRY content assertion for the new launcher, so a launcher pointing at a non-existent app.asar\dist\cli\memmy-project.js would still pass verification. Please align the line with the test and add an ENTRY grep similar to the memmy-memory one.


🧹 Filtered 2 low-confidence OCR finding(s) before posting/fix-loop (duplicate: 2).

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: .env.example, AgentSourceCore/src/cursor-source-turn.ts, AgentSourceCore/src/index.ts, AgentSourceCore/src/source-turn.ts, AgentSourceCore/src/tests/source-turn-review.test.ts, App/backend/local-api-contracts/src/collab-instructions.ts, App/backend/local-api-contracts/src/index.ts, App/backend/local-api-contracts/src/project-instructions.ts, App/backend/package.json, App/backend/src/adapters/inbound/local-api/routes/collab.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: feat/collab-projects-v1.2.8

sunqi added 2 commits October 30, 2026 09:18
- the board card, edit dialog hints, empty state and the local backend's
  validation messages use the name the product now shows
- code, API fields, storage keys and i18n keys stay 'instructions'
…n sharing stops

- deleting the cloud project hands its background and guidelines to the
  creator's local project, replacing whatever the folder had before: the
  shared copy is what everyone worked with, and the local one is older
- the delete response reports whether anything was handed over, and the
  board tells the person it is now kept on this computer
- leaving or being removed still drops the copy: that project is not
  theirs to keep
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: .env.example, AgentSourceCore/src/cursor-source-turn.ts, AgentSourceCore/src/index.ts, AgentSourceCore/src/source-turn.ts, AgentSourceCore/src/tests/source-turn-review.test.ts, App/backend/local-api-contracts/src/collab-instructions.ts, App/backend/local-api-contracts/src/index.ts, App/backend/local-api-contracts/src/project-instructions.ts, App/backend/package.json, App/backend/src/adapters/inbound/local-api/routes/collab.ts
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: feat/collab-projects-v1.2.8

sunqi added 4 commits October 30, 2026 12:18
Memmy's own agent could retrieve a project's tasks only if the person pasted
a link; the skill that teaches the command existed only as a template written
for external agents. Ship it inside memmy-agent so asking "what has X done"
works without leaving Memmy.

- Add App/memmy-agent/src/skills/memmy-project/SKILL.md, adapted from the
  backend template: the preamble states that a referenced conversation already
  arrives with the message, so no search is needed in that case.
- Keep the external template as it is: it carries a Codex sandbox note that
  does not apply inside Memmy, and the two are not meant to be word for word.
- The skill is bundled with src/skills, so SkillsLoader lists its name and
  description in the summary and loads the body on demand. Nothing is written
  into the system prompt.
- Cover it in the skills-loader test: name in the summary, commands only in
  the body.
A member who joined a project had no way to learn that other people's tasks
are context they can read, reference and continue from; the switch that shares
sessions was explained, the thing it produces was not.

Follow the order in which these decisions are actually made, and say one thing
at a time rather than repeating the same instructions at every stage.

- Empty board: only the definition and the two things a person can do there.
  No @ reference hint, because an empty project has nothing to reference yet.
- Board with other members' tasks: a single dismissible line explaining that
  the agent reads the full conversation once one is referenced. Recorded per
  local project id, so rebuilding the cloud record does not ask again.
- Row menu: "Continue task" opens a new task in the same project with that
  conversation referenced. Works on read-only tasks of other members and on
  local-only projects, which get the task without a reference.
- Reader: own tasks state how many members can read and reference them.
- Copy: written language throughout, "task" as the single term, and "the
  conversation joins the project" instead of "syncs to the cloud".
- Correct two strings that contradicted the implementation: a member who
  leaves or is removed takes the conversations they synced with them, so their
  quota is released. Their local project, tasks and files stay.

Move shouldCreateNewAgentDraft into state/agent-composer-state so the board
and the sidebar agree on when a new draft is needed without importing across
page modules; app-frame re-exports it for the existing callers.
- Keep a bound project's Memmy tasks local until they are shared: links.json
  records `sharedSessions`, and `isTaskShared` is the single rule behind
  uploads, the task picker and the per-task toggle. Tasks uploaded before this
  change stay shared, so no migration is needed.
- Add `GET/PUT /api/collab/projects/:projectId/tasks/:conversationId/sharing`
  and `POST /api/collab/sessions/share`, so the desktop app and an agent can
  turn a single task's sharing on or off.
- Put a sharing toggle beside the composer. A task that does not exist yet
  keeps the choice in `collab-share-intent.ts` and applies it once the first
  message creates the session.
- Inject `MEMMY_TASK_ID` into exec and add `memmy-project session share`, so
  asking in the conversation ("share this session with the project") behaves
  the same as the switch. `session add` routes Memmy tasks to the same path.
- Return `conversationId` for a member's own tasks so a board row opens the
  conversation page instead of the read-only panel; other members' tasks and
  other agents' tasks still open the reader.
- Show `项目 / 项目名 / 任务标题` in the conversation header for project tasks
  only, with the project name linking back to the board, and drop the reader's
  "members can read and reference it" line.
Add a teach mode that runs once when a project starts being shared, so the
person learns what the board and a shared task are before using it.

- Six steps: the board and what a collaborative task is, inviting members,
  linking tasks from other agents, sharing a task by conversation,
  continuing a task, and starting the first task.
- Follow the onboarding product tour: a dimmed mask with a spotlight on the
  target, a bubble that follows it, and skip / back / next controls.
- While the tour runs the board shows only the tour's own sample rows:
  real tasks, sync progress, the member tip and paging step aside, so a
  replay never mixes demo rows with the project's actual tasks.
- Start it from a `tourFor` request in the collab store, written when a
  project is created with sharing or when sharing is switched on; joining
  someone else's project and opening other projects stay quiet. Completion
  and skipping both record `memmy.collab.tourSeen`, and project settings
  offers a replay entry.
- Portal the overlay to the body: the board sets `container-type:
  inline-size`, which makes it the containing block for fixed descendants
  and would shift the mask away from the element rectangles.
- Anchor attributes on the board header, invite button, toolbar, link
  button and the sidebar project row, kept inert outside the tour.
@shinetata shinetata changed the title feat(collab): share a project with members and read their agent sessions feat(collab): share projects and tasks with members Oct 30, 2026
sunqi added 2 commits October 30, 2026 16:29
…ries

- add readableSummary in collab-board-text.ts: a summary that stopped on a Cursor or Codex task notification reads as empty, used by the task list, the reader panel, the @ menu and the link picker
- the context tip names the members who left tasks (one name, two joined, or "first and N other members") and falls back to the anonymous wording when nicknames are missing
- tests for the text helpers, the members wording and the hidden notification summary
Creating a sixth project failed to start sharing and reported it as "the
collaboration service is unavailable, retry later". The cloud had in fact
rejected the request because the account already owns as many shared
projects as it may, so both the reason and the advice were wrong.

- Read the cloud's dedicated COLLAB_QUOTA code (41004) and surface it as
  collab_quota_exceeded, so the desktop can tell a quota from an outage.
- Pass maxOwnedProjects through from GET /api/collab/projects: the limit
  stays on the server and the desktop never hardcodes the number.
- Say it before creating: with sharing on at the limit, the dialog row
  explains that this project will only be created on this computer.
- Report the limit on the board after creating, and keep the generic
  notice for other failures.
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: NO TEST SCOPE

Automated tests were not run because the changed files do not map to an executable test scope.

Details: No executable test scope maps to the changed files. Automated tests were not run; add env.yaml source_mapping + execution for this path family, then rerun. Changed files: .env.example, AgentSourceCore/src/cursor-source-turn.ts, AgentSourceCore/src/index.ts, AgentSourceCore/src/source-turn.ts, AgentSourceCore/src/tests/source-turn-review.test.ts, App/backend/local-api-contracts/src/collab-instructions.ts, App/backend/local-api-contracts/src/index.ts, App/backend/local-api-contracts/src/memory-runtime.ts, App/backend/local-api-contracts/src/project-instructions.ts, App/backend/package.json
Manual review or env.yaml source_mapping/execution coverage is required before merge.

Branch: feat/collab-projects-v1.2.8

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants