From 45b59ddb450910204d97cce8c93c192314c36259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=BE=BE=E6=98=8E?= <83948628+tyza66@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:49:46 +0800 Subject: [PATCH] feat(session-controller): offer installed Agent session stores in one dropdown The session management page asked for a source directory path, so importing a Codex, Claude Code or ZCode store meant knowing where that Agent keeps its sessions on this machine and typing the path by hand. The page now asks which Agent to import instead: the Host detects the installed stores and the section offers them in one dropdown with the Import button beside it, so the import opens whatever is actually on disk rather than whatever the user remembered. `listAgentSessionSources` holds the fixed home-directory layout of each foreign Agent and marks one installed only when its marker paths exist, so an Agent that was never run here offers nothing instead of an import that refuses every session. Alpha's own store leads the list and reads the deployment-configured root, so a configured root outside the default layout stays the one this import opens. Detection never opens a Session: the import re-detects the format of whatever root the caller picked. The list reaches the Client through a new `harnessSessionSources` remote and the `sessionSources` client method, and the section shows a detecting state while the Host walks the markers and an empty state when no Agent is found. Only detected installations appear as options, and the selection is disabled until at least one is. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 8 ++ docs/subsystems/session.zh.md | 8 ++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/agent-session-sources.ts | 91 ++++++++++++++++ .../src/client/harness-import.ts | 13 +++ .../src/import-harness-sessions.ts | 12 +++ packages/api/session-controller/src/index.ts | 12 +++ packages/api/session-controller/src/types.ts | 17 +++ .../tests/agent-session-sources.host.spec.ts | 85 +++++++++++++++ .../tests/harness-import.client.spec.ts | 49 ++++++++- .../tests/harness-import.host.spec.ts | 10 +- .../session-controller/tests/test-remote.ts | 3 + .../api/session-controller/tsconfig.host.json | 1 + .../README.i18n.yaml | 4 +- .../ui-settings-import-sessions/README.md | 20 ++-- .../ui-settings-import-sessions/README.zh.md | 20 ++-- .../client/ImportSessionsSection.module.css | 32 ++++-- .../src/client/ImportSessionsSection.tsx | 83 +++++++++------ .../src/client/index.ts | 2 +- .../src/client/locales.ts | 20 ++-- .../tests/browser-plugin.client.spec.tsx | 14 ++- .../tests/components.client.spec.tsx | 100 +++++++++++------- .../extensions/tool-cordis/src/api-catalog.ts | 18 ++++ .../assembly-session-import.client.spec.ts | 6 +- scripts/gen-cordis-catalog.ts | 1 + 38 files changed, 546 insertions(+), 143 deletions(-) create mode 100644 packages/api/session-controller/src/agent-session-sources.ts create mode 100644 packages/api/session-controller/tests/agent-session-sources.host.spec.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f4321bb5f16d..ee43ec107b2a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 109c3c8d399987ff8651c6373746c32def236569 -config-catalog.zh.md: 833efa926e210af813c13e59dddb162fbe74a56e +config-catalog.md: f990d92305262066cf9d057bc6b82998a63be5fa +config-catalog.zh.md: 2d7f1549c85d2413896d06899928ef46834fd646 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 109c3c8d3999..f990d9230526 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -220,7 +220,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:81`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 833efa926e21..2d7f1549c85d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -222,7 +222,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a1e810c8f7e4..7e9a5fd4143c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: ed0e37c6fa4fff86c7145f3e9142fae69a7c0468 -event-producer-consumer.zh.md: 42d75a35bc6b1f7a58788dc23a3e9a7bd33b9c16 +event-producer-consumer.md: 68145bd4f0b94b45c04999851a0b85e00042a754 +event-producer-consumer.zh.md: 0349815b19536f561d808f27f347783b2593444d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ed0e37c6fa4f..68145bd4f0b9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:318`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:419`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:668`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:648`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:654`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:661`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:685`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:665`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:692`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:678`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 42d75a35bc6b..0349815b1953 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:372`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:400`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:613`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:640`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:650`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:630`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:657`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:643`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 8cc0f93426c5..ec2ff90d7fa6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 05f7daaf1708f2e0cae9a7cee9febc160f2f5df6 -persistence-catalog.zh.md: 8c923d557a55d7bb7e18313964bb999303f3b8ce +persistence-catalog.md: e12889b9546e96599546c56acd18b8f0a8a10c02 +persistence-catalog.zh.md: 17e7ce10ea27be4c551e76106a5f90ba61ed5781 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 05f7daaf1708..e12889b9546e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -2749,7 +2749,7 @@ SHA-256: `f6605d6c57933aeaaf10911b5fbd6c61e655baa181af7192d3240f49d255b90d` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:479`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7660,7 +7660,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:457`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:474`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 8c923d557a55..17e7ce10ea27 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -2751,7 +2751,7 @@ SHA-256: `f6605d6c57933aeaaf10911b5fbd6c61e655baa181af7192d3240f49d255b90d` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:479`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7662,7 +7662,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:457`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:474`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index d81024d60b1c..f6cfde72b6cb 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44293,7 +44293,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:457", + "packages/api/session-controller/src/types.ts:474", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -58053,7 +58053,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:462" + "packages/api/session-controller/src/types.ts:479" ] }, { diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index d087875973a2..8f8a0ab3f770 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: f09e05a1f5e6125fb71d927c915610a52f415d19 -session.zh.md: f0a3ae96f5049f59bd0233b9ac4e11c74d5a730f +session.md: 5d2d573868a5145d383ab7548f94ec61be977065 +session.zh.md: 197ab7b21714af64b42dd34d73a0288f9d7732b1 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f09e05a1f5e6..5d2d573868a5 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -904,6 +904,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('harnessSessionsRoot') harnessSessionsRoot(): SessionImportHarnessSourceValue +/** + * List the Agent session stores installed on the Host machine, so a picker + * offers only what exists. Discovery reads directory presence only; no + * Session is opened. + * @returns one entry per installed Agent, plus the configured root. + */ +@Remote('harnessSessionSources') harnessSessionSources(): Promise + /** * Import the official DeepSeek Harness chat history into Alpha's own store. * Read-only over the source root, idempotent, and isolated per Session; it diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index f0a3ae96f504..197ab7b21714 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -908,6 +908,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('harnessSessionsRoot') harnessSessionsRoot(): SessionImportHarnessSourceValue +/** + * List the Agent session stores installed on the Host machine, so a picker + * offers only what exists. Discovery reads directory presence only; no + * Session is opened. + * @returns one entry per installed Agent, plus the configured root. + */ +@Remote('harnessSessionSources') harnessSessionSources(): Promise + /** * Import the official DeepSeek Harness chat history into Alpha's own store. * Read-only over the source root, idempotent, and isolated per Session; it diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3a81feeba495..32c975a6f514 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: d902b70230d6230100a9620620656292f1509d37 -README.zh.md: 843bf07bc4a1247072f2b597f1393f8bd65f7e4b +README.md: 6bc6fc88d86a674033482d28fe1fea797c6a7b35 +README.zh.md: 6b00a71cf4cd4d8e87af0e249b8f29a358519f5c diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index d902b70230d6..6bc6fc88d86a 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -62,7 +62,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `session.importHarnessSessions` adopts the official DeepSeek Harness chat history into Alpha's own Session store, and only when a user asks. It reads the root the `harnessSessionsRoot` Config field names, or the one a request overrides it with, and never writes to that root: discovery selects each Session directory's highest canonical generation, restore migrates an older generation through the build-static format chain, and the storage contract validates the restored log before anything is written. Adoption copies the validated current-format events into the mounted `ctx.sessionPersistence` backend, so the target root and physical encoding remain Alpha's own. -One result is returned per discovered Session — `imported`, `already-present`, or `refused` with its cause. Isolation is per Session, so an unreadable or corrupt source Session is refused while its siblings still import, and a repeated run adopts nothing and reports every Session as already present. The operation never watches, polls, or synchronizes. `harnessSessionsRoot` lets a Client pre-fill its editable source root with the configured default, and the Client face is the `harnessSessionsImport` service, whose `importSessions` re-pulls the Session list once the Host has published what it adopted. +One result is returned per discovered Session — `imported`, `already-present`, or `refused` with its cause. Isolation is per Session, so an unreadable or corrupt source Session is refused while its siblings still import, and a repeated run adopts nothing and reports every Session as already present. The operation never watches, polls, or synchronizes. `harnessSessionSources` lists the Agent session stores installed on the Host machine — Alpha's own store from the configured root, plus each foreign Agent whose marker directory is present — so a picker offers only what exists. The Client face is the `harnessSessionsImport` service, whose `sessionSources` reads that list and whose `importSessions` re-pulls the Session list once the Host has published what it adopted. When the source root is not a DeepSeek Harness tree, the import detects a foreign coding-agent format and converts it into Alpha's current-format events, with the same per-Session results and isolation: Codex (`sessions/YYYY/MM/DD/rollout-*.jsonl`), Claude Code (`projects//*.jsonl`), and Z.ai ZCode (the current SQLite store at `cli/db/db.sqlite`, opened read-only, and the legacy `v2/sessions//.json` snapshots). Detection keys off the root's directory structure, so the source root should be the tool's own data root (for ZCode, the `.zcode` directory). diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 843bf07bc4a1..6b00a71cf4cd 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -62,7 +62,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 `session.importHarnessSessions` 把官方 DeepSeek Harness 的聊天历史采纳进 Alpha 自己的 Session 存储,且仅在用户请求时执行。它读取 `harnessSessionsRoot` Config 字段指定的根目录,或某次请求覆盖的根目录,并且绝不写入该根目录:发现阶段为每个 Session 目录选出最高规范世代,还原阶段通过构建期静态的格式链迁移较旧世代,存储约定则在任何写入之前校验还原出的日志。采纳阶段把校验通过的当前格式事件复制进已挂载的 `ctx.sessionPersistence` 后端,因此目标根目录与物理编码仍归 Alpha 自己所有。 -每个被发现的 Session 返回一条结果——`imported`、`already-present`,或带原因的 `refused`。隔离以单个 Session 为单位,因此不可读或损坏的来源会话会被拒绝,而其同级会话仍会导入;重复运行不会采纳任何内容,并把每个 Session 报告为已存在。该操作从不监视、轮询或同步。`harnessSessionsRoot` 让 Client 用配置的默认值预填可编辑的来源根目录,而 Client 侧接口是 `harnessSessionsImport` 服务,其 `importSessions` 会在 Host 发布采纳结果后重新拉取 Session 列表。 +每个被发现的 Session 返回一条结果——`imported`、`already-present`,或带原因的 `refused`。隔离以单个 Session 为单位,因此不可读或损坏的来源会话会被拒绝,而其同级会话仍会导入;重复运行不会采纳任何内容,并把每个 Session 报告为已存在。该操作从不监视、轮询或同步。`harnessSessionSources` 列出 Host 机器上已安装的 Agent 会话存储——Alpha 自己的存储取自配置的根目录,外加标记目录存在的每个外来 Agent——使选择器只提供真实存在的选项。Client 侧接口是 `harnessSessionsImport` 服务,其 `sessionSources` 读取该列表,其 `importSessions` 会在 Host 发布采纳结果后重新拉取 Session 列表。 ----- diff --git a/packages/api/session-controller/src/agent-session-sources.ts b/packages/api/session-controller/src/agent-session-sources.ts new file mode 100644 index 000000000000..603c8bd6101c --- /dev/null +++ b/packages/api/session-controller/src/agent-session-sources.ts @@ -0,0 +1,91 @@ +/** + * Discovery of the Agent session stores installed on the machine the Host runs on. + * + * The catalog below is fixed: each entry names one Agent, the sessions root the + * import opens for it, and the paths whose presence marks that Agent as + * installed. Nothing here opens a Session — the import re-detects the format of + * whatever root the caller picked, so a root whose layout changed is refused by + * the import rather than misread here. + * @module @mutantcat/dsh-api-session-controller/agent-session-sources + */ + +import { stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { + SessionImportHarnessAgent, + SessionImportHarnessAgentSource, + SessionImportHarnessSourcesValue, +} from './types.ts' + +/** One foreign Agent's fixed home-directory layout. */ +interface ForeignAgentLayout { + /** Agent identity, which also names the store's on-disk format. */ + readonly agent: SessionImportHarnessAgent + /** Sessions root the import reads, relative to the user's home directory. */ + readonly sessionsRoot: readonly string[] + /** + * Paths whose presence marks the Agent as installed, relative to the user's + * home directory. The Agent counts as installed when any of them exists. + */ + readonly installedMarkers: readonly (readonly string[])[] +} + +/** + * Agents other than Alpha's own, with the home-directory layout each one + * writes. Kept beside the format detection in `foreign-format-adapters.ts`, + * which reads whatever root a caller names. + */ +const FOREIGN_AGENT_LAYOUTS: readonly ForeignAgentLayout[] = [ + { + agent: 'codex', + sessionsRoot: ['.codex'], + installedMarkers: [['.codex', 'sessions']], + }, + { + agent: 'claude', + sessionsRoot: ['.claude'], + installedMarkers: [['.claude', 'projects']], + }, + { + agent: 'zcode', + sessionsRoot: ['.zcode'], + installedMarkers: [['.zcode', 'cli', 'db', 'db.sqlite'], ['.zcode', 'v2', 'sessions']], + }, +] + +/** Whether one path exists, treating an unreadable parent as absence. */ +async function pathExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** + * List the Agent session stores installed under the user's home directory. + * + * Alpha's own store leads the list and reads the deployment-configured root, so + * a configured root outside the default layout stays the one this import opens. + * @param configuredRoot - configured root the `dsh` entry reads. + * @returns one entry per installed Agent, with Alpha's own store first. + */ +export async function listAgentSessionSources( + configuredRoot: string, +): Promise { + const home = homedir() + const sources: SessionImportHarnessAgentSource[] = [] + if (await pathExists(configuredRoot)) { + sources.push({ agent: 'dsh', sourceRoot: configuredRoot }) + } + for (const layout of FOREIGN_AGENT_LAYOUTS) { + const markers = await Promise.all( + layout.installedMarkers.map(marker => pathExists(join(home, ...marker))), + ) + if (!markers.some(Boolean)) continue + sources.push({ agent: layout.agent, sourceRoot: join(home, ...layout.sessionsRoot) }) + } + return { sources } +} diff --git a/packages/api/session-controller/src/client/harness-import.ts b/packages/api/session-controller/src/client/harness-import.ts index 240fe3448728..9759a7cf10c8 100644 --- a/packages/api/session-controller/src/client/harness-import.ts +++ b/packages/api/session-controller/src/client/harness-import.ts @@ -7,6 +7,7 @@ import type { ISessions } from './contract/sessions.ts' import type { SessionImportHarnessRequest, SessionImportHarnessValue, + SessionImportHarnessSourcesValue, } from '../types.ts' declare module '@mutantcat/cordis' { @@ -27,6 +28,11 @@ export interface IHarnessSessionsImport { * @returns the configured foreign sessions root. */ sourceRoot(): Promise + /** + * List the Agent session stores installed on the Host machine. + * @returns one entry per installed Agent, plus the configured root. + */ + sessionSources(): Promise /** * Import every Session found under one source root, then refresh the Client * Session list so the adopted Sessions appear in navigation. @@ -68,6 +74,13 @@ export class HarnessSessionsImport implements IHarnessSessionsImport { return result.value.sourceRoot } + /** @inheritdoc */ + async sessionSources(): Promise { + const result = await this.remotes.session.harnessSessionSources() + if (!result.ok) throw commandError('session source discovery', result.error) + return result.value + } + /** @inheritdoc */ async importSessions( request: SessionImportHarnessRequest, diff --git a/packages/api/session-controller/src/import-harness-sessions.ts b/packages/api/session-controller/src/import-harness-sessions.ts index 60a18bccf2b0..606ca8a3c2ed 100644 --- a/packages/api/session-controller/src/import-harness-sessions.ts +++ b/packages/api/session-controller/src/import-harness-sessions.ts @@ -31,11 +31,13 @@ import { readForeignSession, type ForeignFormat, } from './foreign-format-adapters.ts' +import { listAgentSessionSources } from './agent-session-sources.ts' import { RemoteError } from '@mutantcat/dsh-typert-protocol' import type { SessionImportHarnessRequest, SessionImportHarnessResult, SessionImportHarnessValue, + SessionImportHarnessSourcesValue, } from './types.ts' /** Official DeepSeek Harness home directory name; Alpha's own home is `.alpha`. */ @@ -109,6 +111,16 @@ export class HarnessSessionImport { return resolve(expandHomePath(requested)) } + /** + * List the Agent session stores installed on this machine, so a picker offers + * only what exists. Discovery reads directory presence only; no Session is + * opened. + * @returns one entry per installed Agent, with Alpha's own store first. + */ + listSources(): Promise { + return listAgentSessionSources(this.configuredSourceRoot) + } + /** * Import every Session discovered under the resolved source root. * @param request - optional source-root override. diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 104e26aaaac1..fedab96c0f94 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -43,6 +43,7 @@ import type { SessionImportHarnessRequest, SessionImportHarnessSourceValue, SessionImportHarnessValue, + SessionImportHarnessSourcesValue, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, @@ -437,6 +438,17 @@ export class SessionController extends TypertRemoteService { return { sourceRoot: this.harnessImport.sourceRoot } } + /** + * List the Agent session stores installed on the Host machine, so a picker + * offers only what exists. Discovery reads directory presence only; no + * Session is opened. + * @returns one entry per installed Agent, plus the configured root. + */ + @Remote('harnessSessionSources') + harnessSessionSources(): Promise { + return this.harnessImport.listSources() + } + /** * Import the official DeepSeek Harness chat history into Alpha's own store. * Read-only over the source root, idempotent, and isolated per Session; it diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 31de6bec89b7..e7e310f106e3 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -440,6 +440,23 @@ export interface SessionImportHarnessSourceValue { readonly sourceRoot: string } +/** One Agent whose session store this import can adopt. */ +export type SessionImportHarnessAgent = 'dsh' | 'codex' | 'claude' | 'zcode' + +/** One installed Agent's session store, as the import source picker offers it. */ +export interface SessionImportHarnessAgentSource { + /** Agent identity, which also names the store's on-disk format. */ + readonly agent: SessionImportHarnessAgent + /** Sessions root the import reads for this Agent. */ + readonly sourceRoot: string +} + +/** Agent session stores installed on the Host machine. */ +export interface SessionImportHarnessSourcesValue { + /** One entry per installed Agent, in catalog order. */ + readonly sources: readonly SessionImportHarnessAgentSource[] +} + /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { /** File-manager navigation when requested; omission uses the default application. */ diff --git a/packages/api/session-controller/tests/agent-session-sources.host.spec.ts b/packages/api/session-controller/tests/agent-session-sources.host.spec.ts new file mode 100644 index 000000000000..d9c2aa627742 --- /dev/null +++ b/packages/api/session-controller/tests/agent-session-sources.host.spec.ts @@ -0,0 +1,85 @@ +/** + * Discovery of the Agent session stores installed on the machine the Host runs + * on. Every case stubs a throwaway home directory as the user's home, because + * a real `~/.codex` or `~/.claude` on the host would otherwise leak an Agent + * into the expected list. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@mutantcat/cordis' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { listAgentSessionSources } from '../src/agent-session-sources.ts' +import { HarnessSessionImport } from '../src/import-harness-sessions.ts' + +const homes: string[] = [] + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) +}) + +/** + * One throwaway home directory this spec owns, stubbed as the user's home. + * Windows `homedir()` reads `USERPROFILE` rather than `HOME`, so both names are + * stubbed and the real home never contributes an installed Agent. + * @returns the absolute path of the stubbed home directory. + */ +async function home(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-agent-sources-home-')) + homes.push(root) + vi.stubEnv('HOME', root) + if (process.platform === 'win32') vi.stubEnv('USERPROFILE', root) + return root +} + +describe('Agent session source discovery', () => { + it('leads with the Alpha store when the configured root exists and omits it when it does not', async () => { + const root = await home() + const own = join(root, 'sessions') + await mkdir(own, { recursive: true }) + + await expect(listAgentSessionSources(own)).resolves.toEqual({ + sources: [{ agent: 'dsh', sourceRoot: own }], + }) + await expect(listAgentSessionSources(join(root, 'absent'))).resolves.toEqual({ sources: [] }) + }) + + it('lists every installed foreign Agent with its own sessions root', async () => { + const root = await home() + await mkdir(join(root, '.codex', 'sessions'), { recursive: true }) + await mkdir(join(root, '.claude', 'projects'), { recursive: true }) + await mkdir(join(root, '.zcode', 'cli', 'db'), { recursive: true }) + await writeFile(join(root, '.zcode', 'cli', 'db', 'db.sqlite'), '') + + await expect(listAgentSessionSources(join(root, 'absent'))).resolves.toEqual({ + sources: [ + { agent: 'codex', sourceRoot: join(root, '.codex') }, + { agent: 'claude', sourceRoot: join(root, '.claude') }, + { agent: 'zcode', sourceRoot: join(root, '.zcode') }, + ], + }) + }) + + it('counts ZCode as installed on either marker and not on its home directory alone', async () => { + const root = await home() + await mkdir(join(root, '.zcode', 'v2', 'sessions'), { recursive: true }) + await mkdir(join(root, '.codex'), { recursive: true }) + + await expect(listAgentSessionSources(join(root, 'absent'))).resolves.toEqual({ + sources: [{ agent: 'zcode', sourceRoot: join(root, '.zcode') }], + }) + }) + + it('reports the installed sources through the import operation', async () => { + const root = await home() + const own = join(root, 'sessions') + await mkdir(own, { recursive: true }) + const operation = new HarnessSessionImport(new Context(), own) + + await expect(operation.listSources()).resolves.toEqual({ + sources: [{ agent: 'dsh', sourceRoot: own }], + }) + }) +}) diff --git a/packages/api/session-controller/tests/harness-import.client.spec.ts b/packages/api/session-controller/tests/harness-import.client.spec.ts index 1219b7a01fbe..89000e6fcea2 100644 --- a/packages/api/session-controller/tests/harness-import.client.spec.ts +++ b/packages/api/session-controller/tests/harness-import.client.spec.ts @@ -1,6 +1,7 @@ /** * Client Harness-import face: one Remote read for the configured source root, - * one import call, and the Session-list refresh the adopted Sessions need. + * one Remote discovery of the installed Agent sources, one import call, and + * the Session-list refresh the adopted Sessions need. */ import { describe, expect, it, vi } from 'vitest' @@ -15,6 +16,7 @@ const VALUE: SessionImportHarnessValue = { /** Mount the Client face over one stubbed Remote and Session list. */ function harness(session: { harnessSessionsRoot: () => Promise + harnessSessionSources: () => Promise importHarnessSessions: (request: unknown) => Promise }) { const refresh = vi.fn(async () => undefined) @@ -26,15 +28,56 @@ function harness(session: { describe('HarnessSessionsImport', () => { it('reads the configured source root through the Remote', async () => { const root = vi.fn(async () => ({ ok: true, value: { sourceRoot: '/foreign/sessions' } })) - const { face } = harness({ harnessSessionsRoot: root, importHarnessSessions: async () => ({ ok: true, value: VALUE }) }) + const { face } = harness({ + harnessSessionsRoot: root, + harnessSessionSources: async () => ({ ok: true, value: { sources: [] } }), + importHarnessSessions: async () => ({ ok: true, value: VALUE }), + }) await expect(face.sourceRoot()).resolves.toBe('/foreign/sessions') expect(root).toHaveBeenCalledTimes(1) }) + it('lists the installed Agent sources through the Remote', async () => { + const sources = vi.fn(async () => ({ + ok: true, + value: { + sources: [ + { agent: 'dsh', sourceRoot: '/foreign/sessions' }, + { agent: 'codex', sourceRoot: '/home/.codex' }, + ], + }, + })) + const { face } = harness({ + harnessSessionsRoot: async () => ({ ok: true, value: { sourceRoot: '/foreign/sessions' } }), + harnessSessionSources: sources, + importHarnessSessions: async () => ({ ok: true, value: VALUE }), + }) + + await expect(face.sessionSources()).resolves.toEqual({ + sources: [ + { agent: 'dsh', sourceRoot: '/foreign/sessions' }, + { agent: 'codex', sourceRoot: '/home/.codex' }, + ], + }) + expect(sources).toHaveBeenCalledTimes(1) + }) + + it('surfaces a failed source discovery with the Remote failure facts', async () => { + const { face } = harness({ + harnessSessionsRoot: async () => ({ ok: true, value: { sourceRoot: '/foreign/sessions' } }), + harnessSessionSources: async () => ({ ok: false, error: { code: 'gateway/internal', message: 'home unreadable' } }), + importHarnessSessions: async () => ({ ok: true, value: VALUE }), + }) + + await expect(face.sessionSources()).rejects + .toThrow('harness session session source discovery failed: gateway/internal: home unreadable') + }) + it('surfaces a failed source-root read with the Remote failure facts', async () => { const { face } = harness({ harnessSessionsRoot: async () => ({ ok: false, error: { code: 'gateway/internal', message: 'setting unreadable' } }), + harnessSessionSources: async () => ({ ok: true, value: { sources: [] } }), importHarnessSessions: async () => ({ ok: true, value: VALUE }), }) @@ -46,6 +89,7 @@ describe('HarnessSessionsImport', () => { const order: string[] = [] const { face, refresh } = harness({ harnessSessionsRoot: async () => ({ ok: true, value: { sourceRoot: '/foreign/sessions' } }), + harnessSessionSources: async () => ({ ok: true, value: { sources: [] } }), importHarnessSessions: async (request) => { order.push(`import ${JSON.stringify(request)}`) return { ok: true, value: VALUE } @@ -60,6 +104,7 @@ describe('HarnessSessionsImport', () => { it('leaves the Session list alone when the import fails', async () => { const { face, refresh } = harness({ harnessSessionsRoot: async () => ({ ok: true, value: { sourceRoot: '/foreign/sessions' } }), + harnessSessionSources: async () => ({ ok: true, value: { sources: [] } }), importHarnessSessions: async () => ({ ok: false, error: { code: 'gateway/cancelled', message: 'request was aborted' } }), }) diff --git a/packages/api/session-controller/tests/harness-import.host.spec.ts b/packages/api/session-controller/tests/harness-import.host.spec.ts index 95386b5215be..3ed45716b975 100644 --- a/packages/api/session-controller/tests/harness-import.host.spec.ts +++ b/packages/api/session-controller/tests/harness-import.host.spec.ts @@ -189,7 +189,7 @@ async function bench(): Promise<{ } describe('Harness import gateway', () => { - it('reports the configured source root and imports an explicit one on request', async () => { + it('reports the configured source root and installed sources, and imports an explicit root on request', async () => { const targetRoot = await directory('dsh-explore-import-gateway-') const target = await mount(targetRoot) const remote = createSessionTestRemote(target, { @@ -202,6 +202,14 @@ describe('Harness import gateway', () => { if (!configured.ok) throw new Error('expected a configured source root') expect(configured.value.sourceRoot).toBe(configuredHarnessSessionsRoot()) + // Which Agents the picker offers depends on the Host machine, so only each + // entry's shape is checked here; the catalog itself is pinned by + // agent-session-sources.host.spec.ts. + const installed = await remote.harnessSessionSources() + expect(installed.ok).toBe(true) + if (!installed.ok) throw new Error('expected an installed-source list') + expect(installed.value.sources.every(source => source.sourceRoot.length > 0)).toBe(true) + const missing = join(targetRoot, 'absent-source') const imported = await remote.importHarnessSessions({ sourceRoot: missing }) expect(imported.ok).toBe(true) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 15b9c51f894d..6ec9fa4cd25c 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -46,6 +46,7 @@ import type { SessionImportHarnessRequest, SessionImportHarnessSourceValue, SessionImportHarnessValue, + SessionImportHarnessSourcesValue, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, @@ -79,6 +80,7 @@ export interface TestSessionRemote { updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> harnessSessionsRoot(): Promise> + harnessSessionSources(): Promise> importHarnessSessions( request: SessionImportHarnessRequest, signal?: AbortSignal, @@ -361,6 +363,7 @@ export function createSessionTestRemote( updateQueue: request => remoteResult(() => direct.updateQueue(request)), cancel: request => remoteResult(() => direct.cancel(request)), harnessSessionsRoot: () => remoteResult(() => direct.harnessSessionsRoot()), + harnessSessionSources: () => remoteResult(() => direct.harnessSessionSources()), importHarnessSessions: (request, signal = new AbortController().signal) => remoteResult( () => direct.importHarnessSessions(request, signal), signal, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index fef95049d832..060d52b385d3 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -7,6 +7,7 @@ }, "files": [ "src/index.ts", + "src/agent-session-sources.ts", "src/archived-session-gate.ts", "src/types.ts", "src/remote-events.ts", diff --git a/packages/client/ui-settings-import-sessions/README.i18n.yaml b/packages/client/ui-settings-import-sessions/README.i18n.yaml index bfa273782420..39d8e9db373f 100644 --- a/packages/client/ui-settings-import-sessions/README.i18n.yaml +++ b/packages/client/ui-settings-import-sessions/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-import-sessions/README.md -README.md: b1bfe7f317307f0cb9f1fefb310aa6e7c029b7cd -README.zh.md: 3413f7658acef3a9e7dd9a5f4b06d0f127811ed5 +README.md: 0ab70bc377ae0e3d12c14ac57ab9317fc364e896 +README.zh.md: 81b15e127b13fd6ccf0e14383fc35816a2229f94 diff --git a/packages/client/ui-settings-import-sessions/README.md b/packages/client/ui-settings-import-sessions/README.md index b1bfe7f31730..0ab70bc377ae 100644 --- a/packages/client/ui-settings-import-sessions/README.md +++ b/packages/client/ui-settings-import-sessions/README.md @@ -1,5 +1,5 @@ --- -description: "Import-sessions Settings page for the alpha web client: the official DeepSeek Harness sessions directory as an editable source root with one explicit Import action and a per-session result list." +description: "Import-sessions Settings page for the alpha web client: one dropdown of the Agent session stores installed on the Host machine, one explicit Import action, and a per-session result list." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The **Import sessions** Settings page brings the official DeepSeek Harness chat history into Alpha. It shows the source sessions directory, pre-filled from the deployment configuration, lets the user edit it, and runs one import on request. Afterwards it lists one row per discovered session — imported, already present, or refused with the Host's reason — plus a count summary. Nothing runs until the button is pressed: there is no watcher, no polling, and no background sync. +The **Import sessions** Settings page brings other Agents' chat history into Alpha. Opening the page asks the Host which Agent session stores this machine has installed and lists them in one dropdown, Alpha's own store first, so only detected installations appear. Picking an entry and pressing **Import** runs one import from that store. Afterwards it lists one row per discovered session — imported, already present, or refused with the Host's reason — plus a count summary. Nothing runs until the button is pressed: there is no watcher, no polling, and no background sync. ## Table of Contents @@ -25,11 +25,11 @@ The **Import sessions** Settings page brings the official DeepSeek Harness chat ## Use this package -Open Settings and select **Import sessions**, then press **Import**. Mount `@mutantcat/dsh-client-ui-settings-import-sessions` in a Web composition that already provides the settings shell, the locale runtime, and a plugin publishing the `harnessSessionsImport` service; the page registers its own navigation entry and needs no configuration of its own. +Open Settings and select **Import sessions**, pick the Agent whose history you want, then press **Import**. Mount `@mutantcat/dsh-client-ui-settings-import-sessions` in a Web composition that already provides the settings shell, the locale runtime, and a plugin publishing the `harnessSessionsImport` service; the page registers its own navigation entry and needs no configuration of its own. -### Choosing the source directory +### Choosing the source Agent -The source directory field starts with the deployment's configured Harness sessions root, which defaults to the official DeepSeek Harness home's `sessions` directory. Editing the field changes the root for the next import only; leaving it blank falls back to the configured default, which is never the working directory. The page is read-only over this directory: it copies what it adopts into Alpha's own store and never moves, rewrites, or deletes anything there. +The dropdown names the Agent session stores installed on the machine the Host runs on, discovered under the user's home directory. Alpha's own entry reads the deployment-configured Harness sessions root, which defaults to the official DeepSeek Harness home's `sessions` directory, so a configured root outside that default stays the one this import opens. The foreign entries cover the layouts those Agents write — Codex's `~/.codex` directory, Claude Code's `~/.claude/projects`, and ZCode's `~/.zcode` directory — and each appears only while its installation is present, so a store that is not installed is never offered. The page is read-only over every store it opens: it copies what it adopts into Alpha's own store and never moves, rewrites, or deletes anything there. ### Reading the results @@ -47,19 +47,19 @@ The page is one localized `settings.section` contribution with id `import-sessio ### Registration and data sources -`apply()` registers the locale namespace, binds it, and uses `ctx.slots.inject()` to contribute the section, so a late or restored slot declaration still reaches it. The registration closes over `ctx.harnessSessionsImport`: `sourceRoot()` reads the configured default for the pre-filled field, and `importSessions()` runs the import and refreshes the Session list. The page owns no store, no transport, and no refresh logic of its own; it holds the field text, the in-flight state, and the last result as component-local state. +`apply()` registers the locale namespace, binds it, and uses `ctx.slots.inject()` to contribute the section, so a late or restored slot declaration still reaches it. The registration closes over `ctx.harnessSessionsImport`: `sessionSources()` lists the installed Agents and the root each one reads, and `importSessions()` runs the import and refreshes the Session list. The page owns no store, no transport, and no refresh logic of its own; it holds the discovered Agents, the picked root, the in-flight state, and the last result as component-local state. ### Failure handling -A rejected configured-root read or a rejected import call renders the failure message and returns the page to an idle, usable state, so the user can correct the root and try again. Per-session refusals are data, not errors: they arrive inside the successful result and never abort the run. +A rejected discovery read or a rejected import call renders the failure message and returns the page to an idle, usable state, so the user can pick another Agent and try again. Per-session refusals are data, not errors: they arrive inside the successful result and never abort the run. ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Host loader entry: the page is browser-only, so the plugin body is empty | -| [`src/client/index.ts`](src/client/index.ts) | Browser plugin: locale namespace, section registration, injected source-root and import calls | -| [`src/client/ImportSessionsSection.tsx`](src/client/ImportSessionsSection.tsx) | The page component: source-root field, Import action, progress, result rows | +| [`src/client/index.ts`](src/client/index.ts) | Browser plugin: locale namespace, section registration, injected discovery and import calls | +| [`src/client/ImportSessionsSection.tsx`](src/client/ImportSessionsSection.tsx) | The page component: source-Agent dropdown, Import action, progress, result rows | | [`src/client/locales.ts`](src/client/locales.ts) | Chinese and English dictionaries for every visible and accessible string | | [`src/client/ImportSessionsSection.module.css`](src/client/ImportSessionsSection.module.css) | Page styles | @@ -72,7 +72,7 @@ A rejected configured-root read or a rejected import call renders the failure me These pages cover the operation behind the button, the store it writes to, and the format it migrates from. -- [Session Controller](../../api/session-controller/README.md) — the `session.importHarnessSessions` Remote, the `harnessSessionsRoot` Config field, and the client `harnessSessionsImport` face this page reads. +- [Session Controller](../../api/session-controller/README.md) — the `session.importHarnessSessions` and `session.harnessSessionSources` Remotes, the `harnessSessionsRoot` Config field, and the client `harnessSessionsImport` face this page reads. - [session-persistence-jsonl](../../session/session-persistence-jsonl/README.md) — the read-only foreign sessions-root reader that discovers and restores source generations. - [Session subsystem](../../../docs/subsystems/session.md) — the durable Session store the adopted sessions land in. diff --git a/packages/client/ui-settings-import-sessions/README.zh.md b/packages/client/ui-settings-import-sessions/README.zh.md index 3413f7658ace..81b15e127b13 100644 --- a/packages/client/ui-settings-import-sessions/README.zh.md +++ b/packages/client/ui-settings-import-sessions/README.zh.md @@ -1,5 +1,5 @@ --- -description: "alpha Web 客户端的导入会话设置页:把官方 DeepSeek Harness 的 sessions 目录呈现为可编辑的来源目录,配一个显式导入操作与逐会话结果列表。" +description: "alpha Web 客户端的导入会话设置页:以下拉框呈现宿主机上已安装的 Agent 会话存储,配一个显式导入操作与逐会话结果列表。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -**导入会话**设置页把官方 DeepSeek Harness 的聊天历史带入 Alpha。它显示来源 sessions 目录(默认取自部署配置),允许用户修改,并在请求时执行一次导入。随后它按每个发现的会话列出一行——已导入、已存在,或已拒绝并附上宿主给出的原因——以及一行计数汇总。按下按钮之前不会有任何动作:没有监视器、没有轮询,也没有后台同步。 +**导入会话**设置页把其他 Agent 的聊天历史带入 Alpha。页面打开时向宿主询问本机安装了哪些 Agent 会话存储,并把它们列进同一个下拉框,Alpha 自己的存储排在首位;某个存储只有在其安装被检测到时才会出现。选中一项并按**导入**,即从该存储执行一次导入。随后它按每个发现的会话列出一行——已导入、已存在,或已拒绝并附上宿主给出的原因——以及一行计数汇总。按下按钮之前不会有任何动作:没有监视器、没有轮询,也没有后台同步。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -打开设置并选择**导入会话**,然后按**导入**。在已提供设置外壳、locale 运行时,以及发布 `harnessSessionsImport` 服务的插件的 Web 组合中挂载 `@mutantcat/dsh-client-ui-settings-import-sessions`;该页面注册自己的导航条目,本身无需配置。 +打开设置并选择**导入会话**,挑选要导入历史记录的 Agent,然后按**导入**。在已提供设置外壳、locale 运行时,以及发布 `harnessSessionsImport` 服务的插件的 Web 组合中挂载 `@mutantcat/dsh-client-ui-settings-import-sessions`;该页面注册自己的导航条目,本身无需配置。 -### 选择来源目录 +### 选择来源 Agent -来源目录字段以部署配置的 Harness sessions 根目录作为初始值,默认为官方 DeepSeek Harness 主目录下的 `sessions` 目录。修改该字段只影响下一次导入的来源根目录;留空则回退到配置的默认值,而该默认值绝不是工作目录。页面对该目录只读:它把采纳的会话复制进 Alpha 自己的存储,绝不移动、改写或删除那里的任何内容。 +下拉框列出宿主机所运行的机器上已安装的 Agent 会话存储,检测范围是用户主目录。Alpha 自己的条目读取部署配置的 Harness sessions 根目录,默认为官方 DeepSeek Harness 主目录下的 `sessions` 目录,因此配置成该默认布局之外的根目录时,导入打开的仍是它。外来条目覆盖这些 Agent 写入的布局——Codex 的 `~/.codex` 目录、Claude Code 的 `~/.claude/projects`、ZCode 的 `~/.zcode` 目录——且只有在其安装存在时才会出现,未安装的存储绝不会被提供。页面对它打开的每个存储都只读:它把采纳的会话复制进 Alpha 自己的存储,绝不移动、改写或删除那里的任何内容。 ### 阅读结果 @@ -47,19 +47,19 @@ kind: "package-reference" ### 注册与数据来源 -`apply()` 注册并绑定 locale namespace,再用 `ctx.slots.inject()` 贡献该分区,因此延迟声明或恢复声明的 slot 依然能触达它。注册处以 `ctx.harnessSessionsImport` 闭包:`sourceRoot()` 读取配置的默认值用于预填字段,`importSessions()` 执行导入并刷新 Session 列表。页面不持有 store、不持有 transport,也没有自己的刷新逻辑;字段文本、进行中状态与最近一次结果都是组件本地状态。 +`apply()` 注册并绑定 locale namespace,再用 `ctx.slots.inject()` 贡献该分区,因此延迟声明或恢复声明的 slot 依然能触达它。注册处以 `ctx.harnessSessionsImport` 闭包:`sessionSources()` 列出已安装的 Agent 及其各自读取的根目录,`importSessions()` 执行导入并刷新 Session 列表。页面不持有 store、不持有 transport,也没有自己的刷新逻辑;检测到的 Agent、选中的根目录、进行中状态与最近一次结果都是组件本地状态。 ### 失败处理 -配置根目录读取被拒绝或导入调用被拒绝时,页面渲染失败信息并回到空闲可用状态,用户可修改根目录后重试。逐会话的拒绝是数据而非错误:它们随成功结果一起返回,绝不会中止本次运行。 +检测读取被拒绝或导入调用被拒绝时,页面渲染失败信息并回到空闲可用状态,用户可改选其他 Agent 后重试。逐会话的拒绝是数据而非错误:它们随成功结果一起返回,绝不会中止本次运行。 ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 宿主 loader 入口:该页面仅供浏览器使用,因此插件体为空 | -| [`src/client/index.ts`](src/client/index.ts) | 浏览器插件:locale namespace、分区注册、注入的来源根目录与导入调用 | -| [`src/client/ImportSessionsSection.tsx`](src/client/ImportSessionsSection.tsx) | 页面组件:来源目录字段、导入操作、进度、结果行 | +| [`src/client/index.ts`](src/client/index.ts) | 浏览器插件:locale namespace、分区注册、注入的检测与导入调用 | +| [`src/client/ImportSessionsSection.tsx`](src/client/ImportSessionsSection.tsx) | 页面组件:来源 Agent 下拉框、导入操作、进度、结果行 | | [`src/client/locales.ts`](src/client/locales.ts) | 全部可见与无障碍字符串的中英文字典 | | [`src/client/ImportSessionsSection.module.css`](src/client/ImportSessionsSection.module.css) | 页面样式 | @@ -72,7 +72,7 @@ kind: "package-reference" 以下页面覆盖按钮背后的操作、它写入的存储,以及它迁移的来源格式。 -- [Session Controller](../../api/session-controller/README.zh.md)——`session.importHarnessSessions` Remote、`harnessSessionsRoot` Config 字段,以及本页面读取的 client 端 `harnessSessionsImport` 接口。 +- [Session Controller](../../api/session-controller/README.zh.md)——`session.importHarnessSessions` 与 `session.harnessSessionSources` Remote、`harnessSessionsRoot` Config 字段,以及本页面读取的 client 端 `harnessSessionsImport` 接口。 - [session-persistence-jsonl](../../session/session-persistence-jsonl/README.zh.md)——只读的外部 sessions 根目录读取器,负责发现并还原来源世代。 - [Session 子系统](../../../docs/subsystems/session.zh.md)——被采纳会话最终落入的持久 Session 存储。 diff --git a/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.module.css b/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.module.css index 98992d0ce207..8d8eeb5a9575 100644 --- a/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.module.css +++ b/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.module.css @@ -10,6 +10,14 @@ display: flex; flex-direction: column; gap: 6px; + flex: 1 1 auto; + min-width: 0; +} + +.picker { + display: flex; + align-items: flex-end; + gap: 10px; } .label { @@ -18,9 +26,9 @@ color: var(--dsw-alias-label-tertiary); } -.field input { +.input { width: 100%; - max-width: 520px; + max-width: 360px; height: 32px; padding: 0 12px; border: 0.5px solid var(--dsw-alias-border-l2); @@ -30,13 +38,23 @@ font: inherit; } -.field input:disabled { - color: var(--dsw-alias-label-tertiary); +/* Select variant of .input: replaces the OS arrow, which sits flush against + the right edge, with a chevron inset like the settings selects elsewhere. */ +.selectInput { + appearance: none; + padding-right: 32px; + /* Data-URI SVGs cannot resolve CSS variables; #81858C is the caption gray + shared by both themes. */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px 12px; + cursor: pointer; } -.action { - display: flex; - justify-content: flex-start; +.input:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; } .status { diff --git a/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.tsx b/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.tsx index 287c152d2278..4dc5771e259e 100644 --- a/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.tsx +++ b/packages/client/ui-settings-import-sessions/src/client/ImportSessionsSection.tsx @@ -2,9 +2,10 @@ * Session management Settings page: foreign session import plus local session * management (archive and Markdown export). * - * The import section reads the configured source root and imports foreign - * sessions. The local session section lists the current app's sessions with - * archive and export actions. + * The import section discovers which Agent session stores are installed on the + * Host machine, offers them in one dropdown, and imports the picked store. The + * local session section lists the current app's sessions with archive and + * export actions. */ import { useEffect, useRef, useState, type ReactNode } from 'react' import { Button } from '@mutantcat/dsh-client-ui-primitives' @@ -13,16 +14,18 @@ import type { SessionImportHarnessRequest, SessionImportHarnessResult, SessionImportHarnessValue, + SessionImportHarnessAgentSource, + SessionImportHarnessSourcesValue, } from '@mutantcat/dsh-api-session-controller/types' import css from './ImportSessionsSection.module.css' /** Registration-side face used by the page. */ export interface ImportSessionsSectionInjected { /** - * Read the deployment's configured default source sessions root. - * @returns the configured foreign sessions root. + * List the Agent session stores installed on the Host machine. + * @returns one entry per installed Agent, with Alpha's own store first. */ - sourceRoot: () => Promise + sessionSources: () => Promise /** * Import every Session under one source root, then refresh the Session list. * @param request - optional source-root override. @@ -50,6 +53,14 @@ export type ImportSessionsSectionProps = type Translate = ImportSessionsSectionProps['t'] +/** One source Agent's presentation label key. */ +const AGENT_KEY: Readonly> = { + dsh: 'agent.dsh', + codex: 'agent.codex', + claude: 'agent.claude', + zcode: 'agent.zcode', +} + /** One import outcome's presentation label key. */ const STATUS_KEY: Readonly> = { imported: 'status.imported', @@ -100,25 +111,27 @@ function messageOf(reason: unknown): string { * @returns the settings page element tree. */ export function ImportSessionsSection(props: ImportSessionsSectionProps): ReactNode { - const { t, sourceRoot, importSessions, exportSession, archiveSession, useSessions } = props - const [root, setRoot] = useState('') + const { t, sessionSources, importSessions, exportSession, archiveSession, useSessions } = props + const [sources, setSources] = useState([]) + const [selected, setSelected] = useState('') const [ready, setReady] = useState(false) const [running, setRunning] = useState(false) const [value, setValue] = useState(undefined) const [error, setError] = useState(undefined) const [exporting, setExporting] = useState(undefined) const sessions = useSessions(state => state) - // The inject face is rebuilt per registration, so the one-shot read of the - // configured default must not re-run when its identity changes. - const readSourceRoot = useRef(sourceRoot) - readSourceRoot.current = sourceRoot + // The inject face is rebuilt per registration, so the one-shot discovery must + // not re-run when its identity changes. + const readSources = useRef(sessionSources) + readSources.current = sessionSources useEffect(() => { let cancelled = false - readSourceRoot.current().then( - (configured) => { + readSources.current().then( + (discovered) => { if (cancelled) return - setRoot(configured) + setSources(discovered.sources) + setSelected(discovered.sources[0]?.sourceRoot ?? '') setReady(true) }, (reason: unknown) => { @@ -131,11 +144,10 @@ export function ImportSessionsSection(props: ImportSessionsSectionProps): ReactN }, []) const runImport = (): void => { - const requested = root.trim() setRunning(true) setError(undefined) setValue(undefined) - void importSessions(requested === '' ? {} : { sourceRoot: requested }).then( + void importSessions({ sourceRoot: selected }).then( (result) => { setValue(result) setRunning(false) @@ -174,29 +186,36 @@ export function ImportSessionsSection(props: ImportSessionsSectionProps): ReactN return (
{/* Import section */} - - {!ready ?

{t('readingSourceRoot')}

: null} -
+
+
+ {!ready + ?

{t('readingSources')}

+ : sources.length === 0 ?

{t('noAgents')}

: null} {running ?

{t('importing')}

: null} {error === undefined ? null :

{t('failed', { message: error })}

} {value === undefined ? null : ( diff --git a/packages/client/ui-settings-import-sessions/src/client/index.ts b/packages/client/ui-settings-import-sessions/src/client/index.ts index c500365b64de..1a718bd8f9f4 100644 --- a/packages/client/ui-settings-import-sessions/src/client/index.ts +++ b/packages/client/ui-settings-import-sessions/src/client/index.ts @@ -69,7 +69,7 @@ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) const injected = (): ImportSessionsSectionInjected => ({ - sourceRoot: () => ctx.harnessSessionsImport.sourceRoot(), + sessionSources: () => ctx.harnessSessionsImport.sessionSources(), importSessions: request => ctx.harnessSessionsImport.importSessions(request), exportSession: async (sessionId, title) => { const markdown = await ctx.harnessSessionsImport.exportSession(sessionId) diff --git a/packages/client/ui-settings-import-sessions/src/client/locales.ts b/packages/client/ui-settings-import-sessions/src/client/locales.ts index 964cc4e33d3a..fc7101b5e0c5 100644 --- a/packages/client/ui-settings-import-sessions/src/client/locales.ts +++ b/packages/client/ui-settings-import-sessions/src/client/locales.ts @@ -3,9 +3,13 @@ /** Simplified Chinese dictionary and key source of truth. */ export const zh = { nav: '会话管理', - sourceRoot: '来源目录', - sourceRootHint: '官方 DeepSeek Harness 的 sessions 目录', - readingSourceRoot: '正在读取来源目录…', + sourceAgent: '来源 Agent', + 'agent.dsh': 'DeepSeek Harness', + 'agent.codex': 'Codex', + 'agent.claude': 'Claude Code', + 'agent.zcode': 'ZCode', + readingSources: '正在检测已安装的 Agent…', + noAgents: '未检测到已安装的 Agent。', import: '导入', importing: '正在导入…', emptyResults: '来源目录中没有会话。', @@ -34,9 +38,13 @@ export type ImportSessionsLocaleKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { nav: 'Session management', - sourceRoot: 'Source directory', - sourceRootHint: 'The official DeepSeek Harness sessions directory', - readingSourceRoot: 'Reading the source directory…', + sourceAgent: 'Source Agent', + 'agent.dsh': 'DeepSeek Harness', + 'agent.codex': 'Codex', + 'agent.claude': 'Claude Code', + 'agent.zcode': 'ZCode', + readingSources: 'Detecting installed Agents…', + noAgents: 'No installed Agent detected.', import: 'Import', importing: 'Importing…', emptyResults: 'The source directory holds no sessions.', diff --git a/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx index e0160b446671..a79474cbf992 100644 --- a/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-import-sessions/tests/browser-plugin.client.spec.tsx @@ -20,16 +20,18 @@ async function bench() { await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) - const sourceRoot = vi.fn(async () => '/source/sessions') + const sessionSources = vi.fn(async () => ({ + sources: [{ agent: 'dsh' as const, sourceRoot: '/source/sessions' }], + })) const importSessions = vi.fn(async (): Promise => ({ sourceRoot: '/source/sessions', results: [], })) const exportSession = vi.fn(async () => '# Export') const archiveSession = vi.fn(async () => {}) - ctx.provide('harnessSessionsImport', { sourceRoot, importSessions, exportSession }) + ctx.provide('harnessSessionsImport', { sessionSources, importSessions, exportSession }) ctx.provide('uiWorkspace', { archiveSession }) - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, sourceRoot, importSessions, exportSession, archiveSession } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, sessionSources, importSessions, exportSession, archiveSession } } function declare(slots: SlotRegistry): () => void { @@ -63,10 +65,12 @@ describe('ui-settings-import-sessions browser plugin', () => { expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Session management') const injected = (entry.inject as unknown as () => ImportSessionsSectionInjected)() - await expect(injected.sourceRoot()).resolves.toBe('/source/sessions') + await expect(injected.sessionSources()).resolves.toEqual({ + sources: [{ agent: 'dsh', sourceRoot: '/source/sessions' }], + }) await expect(injected.importSessions({ sourceRoot: '/other/sessions' })) .resolves.toMatchObject({ sourceRoot: '/source/sessions' }) - expect(b.sourceRoot).toHaveBeenCalled() + expect(b.sessionSources).toHaveBeenCalled() expect(b.importSessions).toHaveBeenCalledWith({ sourceRoot: '/other/sessions' }) await b.ctx.fiber.dispose() }) diff --git a/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx b/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx index ed0c14f2734c..30d33c8457f1 100644 --- a/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-import-sessions/tests/components.client.spec.tsx @@ -2,7 +2,11 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionListState, SessionSummary } from '@mutantcat/dsh-api-session-controller/client' -import type { SessionImportHarnessValue } from '@mutantcat/dsh-api-session-controller/types' +import type { + SessionImportHarnessAgentSource, + SessionImportHarnessValue, + SessionImportHarnessSourcesValue, +} from '@mutantcat/dsh-api-session-controller/types' import type { SessionId } from '@mutantcat/dsh-session/types' import { ImportSessionsSection } from '../src/client/ImportSessionsSection.tsx' import type { ImportSessionsSectionProps } from '../src/client/ImportSessionsSection.tsx' @@ -38,17 +42,18 @@ function value(results: SessionImportHarnessValue['results']): SessionImportHarn } function props(options: { - configured?: string - sourceRoot?: () => Promise + sources?: readonly SessionImportHarnessAgentSource[] + sessionSources?: ImportSessionsSectionProps['sessionSources'] importSessions?: ImportSessionsSectionProps['importSessions'] exportSession?: ImportSessionsSectionProps['exportSession'] archiveSession?: ImportSessionsSectionProps['archiveSession'] sessions?: SessionListState useSessions?: ImportSessionsSectionProps['useSessions'] } = {}): ImportSessionsSectionProps { + const sources = options.sources ?? [{ agent: 'dsh' as const, sourceRoot: '/source/sessions' }] return { t, - sourceRoot: options.sourceRoot ?? (async () => options.configured ?? '/source/sessions'), + sessionSources: options.sessionSources ?? (async () => ({ sources })), importSessions: options.importSessions ?? (async () => value([])), exportSession: options.exportSession ?? (async () => {}), archiveSession: options.archiveSession ?? (async () => {}), @@ -58,18 +63,26 @@ function props(options: { } describe('ImportSessionsSection', () => { - it('prefills the configured source root and imports that root on request', async () => { + it('selects the first installed Agent and imports its sessions on request', async () => { const importSessions = vi.fn(async () => value([ { sourcePath: '/source/sessions/project/one', sessionId: sid('one'), status: 'imported' as const }, ])) - render() + render() - const field = await screen.findByLabelText(en.sourceRoot) as HTMLInputElement - await waitFor(() => { expect(field.value).toBe('/configured/sessions') }) + const picker = await screen.findByLabelText(en.sourceAgent) as HTMLSelectElement + await waitFor(() => { expect(picker.value).toBe('/configured/sessions') }) fireEvent.click(screen.getByRole('button', { name: en.import })) await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/configured/sessions' }) }) + expect(screen.getByRole('option', { name: en['agent.dsh'] })).toBeTruthy() + expect(screen.getByRole('option', { name: en['agent.codex'] })).toBeTruthy() expect(await screen.findByText(en['status.imported'])).toBeTruthy() expect(screen.getByText(en.resultSession.replace('{id}', 'one'))).toBeTruthy() expect(screen.getByText(en.summary @@ -77,18 +90,20 @@ describe('ImportSessionsSection', () => { .replace('{alreadyPresent}', '0').replace('{refused}', '0'))).toBeTruthy() }) - it('imports the root the user edits and omits an emptied field', async () => { + it('imports the Agent the user picks', async () => { const importSessions = vi.fn(async () => value([])) - render() - - const field = await screen.findByLabelText(en.sourceRoot) as HTMLInputElement - fireEvent.change(field, { target: { value: '/elsewhere/sessions' } }) - fireEvent.click(screen.getByRole('button', { name: en.import })) - await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/elsewhere/sessions' }) }) + render() - fireEvent.change(field, { target: { value: ' ' } }) + const picker = await screen.findByLabelText(en.sourceAgent) as HTMLSelectElement + fireEvent.change(picker, { target: { value: '/home/.claude' } }) fireEvent.click(screen.getByRole('button', { name: en.import })) - await waitFor(() => { expect(importSessions).toHaveBeenLastCalledWith({}) }) + await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/home/.claude' }) }) }) it('reports an empty source directory and a refusal reason', async () => { @@ -99,7 +114,7 @@ describe('ImportSessionsSection', () => { ]), })} />) - await screen.findByLabelText(en.sourceRoot) + await screen.findByLabelText(en.sourceAgent) fireEvent.click(screen.getByRole('button', { name: en.import })) expect(await screen.findByText(en['status.refused'])).toBeTruthy() @@ -112,17 +127,25 @@ describe('ImportSessionsSection', () => { it('shows the empty-source message when the scan finds nothing', async () => { render() - await screen.findByLabelText(en.sourceRoot) + await screen.findByLabelText(en.sourceAgent) fireEvent.click(screen.getByRole('button', { name: en.import })) expect(await screen.findByText(en.emptyResults)).toBeTruthy() }) + it('offers no import and names the cause when no Agent is installed', async () => { + render() + + expect(await screen.findByText(en.noAgents)).toBeTruthy() + expect(screen.queryByText(en.readingSources)).toBeNull() + expect(screen.getByRole('button', { name: en.import }).hasAttribute('disabled')).toBe(true) + }) + it('surfaces a failed call and stays usable', async () => { const importSessions = vi.fn(async () => { throw new Error('gateway unavailable') }) render() - await screen.findByLabelText(en.sourceRoot) + await screen.findByLabelText(en.sourceAgent) fireEvent.click(screen.getByRole('button', { name: en.import })) expect(await screen.findByText(en.failed.replace('{message}', 'gateway unavailable'))).toBeTruthy() @@ -134,41 +157,46 @@ describe('ImportSessionsSection', () => { importSessions: async () => { throw 'gateway offline' }, })} />) - await screen.findByLabelText(en.sourceRoot) + await screen.findByLabelText(en.sourceAgent) fireEvent.click(screen.getByRole('button', { name: en.import })) expect(await screen.findByText(en.failed.replace('{message}', 'gateway offline'))).toBeTruthy() }) - it('ignores a configured-root read that settles after the page unmounts', async () => { - const settles: Array<{ done: (value: string) => void; fail: (reason: unknown) => void }> = [] - const pending = (): Promise => new Promise((done, fail) => { - settles.push({ done, fail }) - }) - const resolved = render() - const rejected = render() + it('ignores a discovery read that settles after the page unmounts', async () => { + const settles: Array<{ + done: (value: SessionImportHarnessSourcesValue) => void + fail: (reason: unknown) => void + }> = [] + const pending = (): Promise => + new Promise((done, fail) => { + settles.push({ done, fail }) + }) + const resolved = render() + const rejected = render() resolved.unmount() rejected.unmount() - settles[0]!.done('/configured/sessions') + settles[0]!.done({ sources: [{ agent: 'dsh', sourceRoot: '/configured/sessions' }] }) settles[1]!.fail(new Error('remote session unavailable')) await new Promise(resolve => setTimeout(resolve, 0)) - expect(screen.queryByLabelText(en.sourceRoot)).toBeNull() + expect(screen.queryByLabelText(en.sourceAgent)).toBeNull() }) - it('surfaces a failed configured-root read and lets the user type one instead', async () => { + it('surfaces a failed discovery read and offers no import', async () => { const importSessions = vi.fn(async () => value([])) render( { throw new Error('remote session unavailable') }, + sessionSources: async () => { throw new Error('remote session unavailable') }, importSessions, })} />) expect(await screen.findByText(en.failed.replace('{message}', 'remote session unavailable'))).toBeTruthy() - const field = screen.getByLabelText(en.sourceRoot) as HTMLInputElement - fireEvent.change(field, { target: { value: '/typed/sessions' } }) - fireEvent.click(screen.getByRole('button', { name: en.import })) - await waitFor(() => { expect(importSessions).toHaveBeenCalledWith({ sourceRoot: '/typed/sessions' }) }) + // Discovery named no Agent, so neither the picker nor the import is offered. + const picker = screen.getByLabelText(en.sourceAgent) as HTMLSelectElement + expect(picker.hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: en.import }).hasAttribute('disabled')).toBe(true) + expect(importSessions).not.toHaveBeenCalled() }) it('exports a listed session with its id and title', async () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0cf200d7c51a..13a3ebc155e3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1818,6 +1818,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'the configured foreign sessions root.', }, + { + signature: '@Remote(\'harnessSessionSources\') harnessSessionSources(): Promise', + description: 'List the Agent session stores installed on the Host machine, so a picker offers only what exists. Discovery reads directory presence only; no Session is opened.', + parameters: [], + returns: 'one entry per installed Agent, plus the configured root.', + }, { signature: '@Remote(\'importHarnessSessions\') importHarnessSessions( request: SessionImportHarnessRequest, signal: AbortSignal, ): Promise', description: 'Import the official DeepSeek Harness chat history into Alpha\'s own store. Read-only over the source root, idempotent, and isolated per Session; it never watches or synchronizes on its own.', @@ -6161,6 +6167,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionImportHarnessAgent', + declaration: 'export type SessionImportHarnessAgent = \'dsh\' | \'codex\' | \'claude\' | \'zcode\';', + }, + { + name: 'SessionImportHarnessAgentSource', + declaration: 'export interface SessionImportHarnessAgentSource {\n readonly agent: SessionImportHarnessAgent;\n readonly sourceRoot: string;\n}', + }, { name: 'SessionImportHarnessRequest', declaration: 'export interface SessionImportHarnessRequest {\n readonly sourceRoot?: string;\n}', @@ -6169,6 +6183,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionImportHarnessResult', declaration: 'export interface SessionImportHarnessResult {\n readonly sourcePath: string;\n readonly sessionId?: SessionId;\n readonly status: SessionImportHarnessStatus;\n readonly reason?: string;\n}', }, + { + name: 'SessionImportHarnessSourcesValue', + declaration: 'export interface SessionImportHarnessSourcesValue {\n readonly sources: readonly SessionImportHarnessAgentSource[];\n}', + }, { name: 'SessionImportHarnessSourceValue', declaration: 'export interface SessionImportHarnessSourceValue {\n readonly sourceRoot: string;\n}', diff --git a/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts index f69ec18d7e3b..468cb23f905e 100644 --- a/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-session-import.client.spec.ts @@ -23,9 +23,10 @@ const CONNECTION_PACKAGE = '@mutantcat/dsh-client-connection' const IMPORT_SESSIONS_PACKAGE = '@mutantcat/dsh-client-ui-settings-import-sessions' describe('session-management import face over the mounted namespaces', () => { - it('answers source-root, import, and export reads from a consumer fiber', async () => { + it('answers source-root, source-list, import, and export reads from a consumer fiber', async () => { const mock = RemoteMock.create().load(remoteDefaultResponses) .unary('session/harnessSessionsRoot', ok({ sourceRoot: '/source/sessions' })) + .unary('session/harnessSessionSources', ok({ sources: [{ agent: 'dsh', sourceRoot: '/source/sessions' }] })) .unary('session/importHarnessSessions', ok({ sourceRoot: '/source/sessions', results: [] })) .unary('session/exportSessionMarkdown', ok({ markdown: '# Transcript' })) const roster = webApp.closure([IMPORT_SESSIONS_PACKAGE]) @@ -46,6 +47,7 @@ describe('session-management import face over the mounted namespaces', () => { interface PendingReads { sourceRoot: Promise + sources: Promise imported: Promise exported: Promise } @@ -55,6 +57,7 @@ describe('session-management import face over the mounted namespaces', () => { apply: (consumerCtx) => { pending = { sourceRoot: consumerCtx.harnessSessionsImport.sourceRoot(), + sources: consumerCtx.harnessSessionsImport.sessionSources(), imported: consumerCtx.harnessSessionsImport.importSessions({ sourceRoot: '/source/sessions' }), exported: consumerCtx.harnessSessionsImport.exportSession('session-1'), } @@ -63,6 +66,7 @@ describe('session-management import face over the mounted namespaces', () => { expect(pending).toBeDefined() await expect(pending!.sourceRoot).resolves.toBe('/source/sessions') + await expect(pending!.sources).resolves.toEqual({ sources: [{ agent: 'dsh', sourceRoot: '/source/sessions' }] }) await expect(pending!.imported).resolves.toEqual({ sourceRoot: '/source/sessions', results: [] }) await expect(pending!.exported).resolves.toBe('# Transcript') }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 51c96d646d9c..940f2e569be8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -388,6 +388,7 @@ export const LINK_MAP: Readonly> = { SessionImportHarnessRequest: 'session.md', SessionImportHarnessValue: 'session.md', SessionImportHarnessSourceValue: 'session.md', + SessionImportHarnessSourcesValue: 'session.md', SessionModels: 'session.md', SessionModelsRequest: 'session.md', SessionPage: 'session.md',