From 3bcca11f3fbef45bc676188aec6469d1a19b31b7 Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 24 Oct 2026 15:41:33 +0000 Subject: [PATCH] mcp: record session titles from the hook --- packages/simplex-chat-mcp/REFERENCE.md | 3 +- packages/simplex-chat-mcp/TESTING.md | 3 +- packages/simplex-chat-mcp/spec/README.md | 8 +- packages/simplex-chat-mcp/spec/api.md | 38 +++---- .../simplex-chat-mcp/spec/architecture.md | 83 ++++++++-------- packages/simplex-chat-mcp/spec/flows.md | 37 +++---- packages/simplex-chat-mcp/src/daemon.ts | 75 +++++++++++--- packages/simplex-chat-mcp/src/hook.ts | 7 +- packages/simplex-chat-mcp/src/protocol.ts | 7 ++ .../simplex-chat-mcp/test/unit/daemon.test.ts | 99 ++++++++++++++++++- .../simplex-chat-mcp/test/unit/hook.test.ts | 22 +++-- 11 files changed, 274 insertions(+), 108 deletions(-) diff --git a/packages/simplex-chat-mcp/REFERENCE.md b/packages/simplex-chat-mcp/REFERENCE.md index b10e924396..af334cab17 100644 --- a/packages/simplex-chat-mcp/REFERENCE.md +++ b/packages/simplex-chat-mcp/REFERENCE.md @@ -57,7 +57,7 @@ The marketplace `simplex-chat` lists one plugin, `simplex`, from the npm package | Part | What it does | |---|---| | MCP server `simplex` | runs `npx -y @simplex-chat/mcp@0.2.0 session` for each session; its tools are `mcp__plugin_simplex_simplex__connect` and the rest | -| SessionStart hook | runs `simplex-mcp hook session-start`: checks the Node.js version, starts the daemon, opens the session's profile, and shows the Main group link while the group has no owner | +| SessionStart hook | runs `simplex-mcp hook session-start`: checks the Node.js version, starts the daemon, records the session title, and shows the Main group link while the group has no owner | | PreToolUse hook | allows the plugin's tool calls without a Claude Code prompt, because you approve in the SimpleX app | | `/simplex:link` | shows the Main group link, or its owners | | `/simplex:reset-owners` | replaces the Main group and its owners | @@ -99,6 +99,7 @@ Plugin option expire_hours: hours from 1 to 23 expected, got 48. Change it in /p Each Claude Code session ID has its own SimpleX profile, with its own chats, contacts and incognito profiles. The profile name is `SimpleX MCP - `: the session name from `claude -n NAME` or `/rename`, else the name of the project folder, else `Claude Code`. The name follows the display name rules: characters a display name can't hold are left out, and it keeps at most 50 characters. A session renamed with `/rename` gets the new profile name at its next start, for example with `claude --resume`. - Each session has a number, 1 for the first, kept across daemon restarts. Cards and owner commands use it: `Session 2`, `2:@3`. +- The session's MCP server creates its profile when it first reaches the daemon; the SessionStart hook only records the title. After `/clear`, Claude Code keeps the MCP server and its session ID, so the session keeps its profile, and no profile is created for the new ID the hook sees. - `claude --resume` reopens the session's profile with its chats. - A session profile is not a member of the Main group. Peers of a chat see only the profile of that session, or the incognito profile of that chat. - One daemon runs all profiles. It opens a session's profile at the session's first request, closes it 10 minutes after the session ends when it has no action, and stops 10 minutes after the last profile closes. The next session start or tool call starts it again. diff --git a/packages/simplex-chat-mcp/TESTING.md b/packages/simplex-chat-mcp/TESTING.md index 239d985f2e..0978adbda3 100644 --- a/packages/simplex-chat-mcp/TESTING.md +++ b/packages/simplex-chat-mcp/TESTING.md @@ -61,7 +61,8 @@ After a change in `src/`, repeat step 1 and stop the running daemon, which a dae 6. Change `main_group` in `/config`, then start a new session: the Main group and its profile get the new name. 7. Mode `requests`: tool calls run without a Claude Code prompt (PreToolUse hook). Mode `host` (`approve` in `/config`, then a new session): `connect`, `send` and `leave` make Claude Code prompt; after you allow a send, the Main group gets the outcome card ending with `Performed without approval (mode host).` 8. `/simplex:reset-owners`: the old Main group is deleted, and the output is `"SimpleX MCP" was replaced. Join the new one in the SimpleX app. The link works once:` with the link. Until you join, a `connect` is refused with `The agent has no owner. Open this link in the SimpleX app and join: `. -9. Stop the daemon while a `receive` with `wait` runs: `kill $(cat ~/.simplex-mcp/lock)`. The call returns `The SimpleX daemon stopped; repeat the call.`, and the repeated call starts a new daemon. +9. `/clear` in a session: `~/.simplex-mcp/_sessions` gets no new folder, and the next tool call uses the same profile; its cards keep the session number. +10. Stop the daemon while a `receive` with `wait` runs: `kill $(cat ~/.simplex-mcp/lock)`. The call returns `The SimpleX daemon stopped; repeat the call.`, and the repeated call starts a new daemon. ## Try the tools diff --git a/packages/simplex-chat-mcp/spec/README.md b/packages/simplex-chat-mcp/spec/README.md index 8389ee740f..dd18bd8eed 100644 --- a/packages/simplex-chat-mcp/spec/README.md +++ b/packages/simplex-chat-mcp/spec/README.md @@ -23,7 +23,7 @@ The daemon runs one SimpleX core (libsimplex, loaded by the `simplex-chat` libra A tool call is held open until the owner decides and, for `connect` and `send`, until the replies arrive. A repeated call with the same arguments is attached to the same action, so a call that the host ends early is resumed. -The daemon keeps actions in memory, and they are lost when it exits. The action counter is stored as custom data of the Main group, and the standing approvals as custom data of the approved chats. The token, the lock, the log and each session's number and name are files in the data folder. Options come from the plugin's `userConfig` through environment variables; the plugin's hooks start the daemon and show the Main group link. +The daemon keeps actions in memory, and they are lost when it exits. The action counter is stored as custom data of the Main group, and the standing approvals as custom data of the approved chats. The token, the lock, the log and each session's number and name are files in the data folder. Options come from the plugin's `userConfig` through environment variables; the SessionStart hook starts the daemon, records the session title and shows the Main group link, and the session process creates the session's profile. --- @@ -137,7 +137,7 @@ graph TD | [`actions.ts`](../src/actions.ts) | action records, state machine, per-session limits, expiry, stop | | [`control.ts`](../src/control.ts) | Main group: link, owners, owner commands with session references, cards, counter | | [`messages.ts`](../src/messages.ts) | message items, item watch, waits, unread paging, display names | -| [`hook.ts`](../src/hook.ts) | `hook session-start`: Node.js check, daemon start, session registration, link output | +| [`hook.ts`](../src/hook.ts) | `hook session-start`: Node.js check, daemon start, session title, link output | | [`owner.ts`](../src/owner.ts) | `link` and `reset-owners` | | [`config.ts`](../src/config.ts) | plugin option variables to `Config`, validation | | [`files.ts`](../src/files.ts) | data root layout, lock, token, session folders and `session.json`, socket path, log file | @@ -180,8 +180,8 @@ Plugin files, run by Claude Code without `node_modules`: | Daemon start by a client | [`src/client.ts`](../src/client.ts#L32) | `DaemonClient.ensureDaemon` | [32](../src/client.ts#L32) | | Daemon spawn | [`src/client.ts`](../src/client.ts#L68) | `spawnDaemon` | [68](../src/client.ts#L68) | | Socket listener | [`src/socket.ts`](../src/socket.ts#L18) | `listen` | [18](../src/socket.ts#L18) | -| Daemon process | [`src/daemon.ts`](../src/daemon.ts#L285) | `runDaemon` | [285](../src/daemon.ts#L285) | -| Daemon routes | [`src/daemon.ts`](../src/daemon.ts#L101) | `Daemon.routes` | [101](../src/daemon.ts#L101) | +| Daemon process | [`src/daemon.ts`](../src/daemon.ts#L334) | `runDaemon` | [334](../src/daemon.ts#L334) | +| Daemon routes | [`src/daemon.ts`](../src/daemon.ts#L103) | `Daemon.routes` | [103](../src/daemon.ts#L103) | | Hub and Main group | [`src/hub.ts`](../src/hub.ts#L26) | `Hub.open` | [26](../src/hub.ts#L26) | | Session profile | [`src/agent.ts`](../src/agent.ts#L39) | `openSessionAgent` | [39](../src/agent.ts#L39) | | SimpleX core | [`src/core.ts`](../src/core.ts#L21) | `openCore` | [21](../src/core.ts#L21) | diff --git a/packages/simplex-chat-mcp/spec/api.md b/packages/simplex-chat-mcp/spec/api.md index 6261ab15ae..b8238484a0 100644 --- a/packages/simplex-chat-mcp/spec/api.md +++ b/packages/simplex-chat-mcp/spec/api.md @@ -48,8 +48,8 @@ | `additionalProperties: false` is set in every input schema | [tools.ts:177](../src/tools.ts#L177), [:192](../src/tools.ts#L192), [:203](../src/tools.ts#L203), [:206](../src/tools.ts#L206), [:214](../src/tools.ts#L214) | | A result is sent as `structuredContent` and as its JSON in one text item | [tools.ts:372](../src/tools.ts#L372) | | A tool error is sent as `isError: true` with one text item: the `ToolError` text, or the `errorText` of other errors | [tools.ts:374](../src/tools.ts#L374), [log.ts:30-34](../src/log.ts#L30-L34) | -| With a `progressToken` in `_meta`, `notifications/progress` is sent at the start of each wait and every 60 s, with `progress` 0, 1, 2, ... and a `message`; the daemon sends each message as a `{progress}` line and the session process forwards it | [tools.ts:995-1004](../src/tools.ts#L995-L1004), [messages.ts:202-206](../src/messages.ts#L202-L206), [daemon.ts:198](../src/daemon.ts#L198), [session.ts:99-103](../src/session.ts#L99-L103) | -| The call is aborted when Claude Code cancels the request: the session process closes its `/call` request and the daemon aborts the call's signal; the action is kept | [session.ts:99-103](../src/session.ts#L99-L103), [daemon.ts:192-194](../src/daemon.ts#L192-L194) | +| With a `progressToken` in `_meta`, `notifications/progress` is sent at the start of each wait and every 60 s, with `progress` 0, 1, 2, ... and a `message`; the daemon sends each message as a `{progress}` line and the session process forwards it | [tools.ts:995-1004](../src/tools.ts#L995-L1004), [messages.ts:202-206](../src/messages.ts#L202-L206), [daemon.ts:234](../src/daemon.ts#L234), [session.ts:99-103](../src/session.ts#L99-L103) | +| The call is aborted when Claude Code cancels the request: the session process closes its `/call` request and the daemon aborts the call's signal; the action is kept | [session.ts:99-103](../src/session.ts#L99-L103), [daemon.ts:228-230](../src/daemon.ts#L228-L230) | | A call of the same session with the same tool and action fields as an action in memory is attached to it | [tools.ts:987-989](../src/tools.ts#L987-L989) | | Messages are marked read after the last wait of a call that is not aborted | [tools.ts:462-463](../src/tools.ts#L462-L463), [:600-602](../src/tools.ts#L600-L602), [:682-683](../src/tools.ts#L682-L683) | @@ -399,28 +399,32 @@ The hub profile that posts in the group has the same display name and peer type ## 3. Socket protocol -HTTP/1.1 over `ROOT/daemon.sock` (or the fallback path, or the Windows pipe), protocol version `1`. Every request carries `Authorization: Bearer TOKEN`; bodies are JSON; streamed replies are NDJSON with content type `application/x-ndjson`. Errors are `{"error": TEXT}` with a status other than 200. Types: [`protocol.ts`](../src/protocol.ts); routes: [`Daemon.routes`](../src/daemon.ts#L101-L113). +HTTP/1.1 over `ROOT/daemon.sock` (or the fallback path, or the Windows pipe), protocol version `1`. Every request carries `Authorization: Bearer TOKEN`; bodies are JSON; streamed replies are NDJSON with content type `application/x-ndjson`. Errors are `{"error": TEXT}` with a status other than 200. Types: [`protocol.ts`](../src/protocol.ts); routes: [`Daemon.routes`](../src/daemon.ts#L103-L116). | Route | Request | Reply | Code | |---|---|---|---| -| `POST /session` | `{id, name?, project, config: {mainGroup, approve, expireHours}}` | `{number, displayName, version, protocol, owner: {owners, link?}}` | [daemon.ts:159-172](../src/daemon.ts#L159-L172) | -| `POST /attach` | `{id}` | stream held open; first line `{version, protocol}` | [daemon.ts:174-185](../src/daemon.ts#L174-L185) | -| `POST /call` | `{id, tool, input}` | lines `{progress: TEXT}`, then one line `{result: CallToolResult or null}`; closing the request aborts the call | [daemon.ts:187-204](../src/daemon.ts#L187-L204) | -| `GET /status` | none | `{version, protocol, mainGroup, owner: {owners, link?}, sessions: [{number, name, attached}]}` | [daemon.ts:279-282](../src/daemon.ts#L279-L282) | -| `POST /reset-owners` | none | `{link}` | [daemon.ts:107](../src/daemon.ts#L107) | -| `POST /stop` | none | `{}` after the stop sequence; the process exits after the reply | [daemon.ts:108-111](../src/daemon.ts#L108-L111) | +| `POST /session` | `{id, name?, project, config: {mainGroup, approve, expireHours}}` | `{number, displayName, version, protocol, owner: {owners, link?}}` | [daemon.ts:162-170](../src/daemon.ts#L162-L170) | +| `POST /title` | `{id, name?, project, config}` | `{mainGroup, owner: {owners, link?}, version, protocol}` | [daemon.ts:172-208](../src/daemon.ts#L172-L208) | +| `POST /attach` | `{id}` | stream held open; first line `{version, protocol}` | [daemon.ts:210-221](../src/daemon.ts#L210-L221) | +| `POST /call` | `{id, tool, input}` | lines `{progress: TEXT}`, then one line `{result: CallToolResult or null}`; closing the request aborts the call | [daemon.ts:223-240](../src/daemon.ts#L223-L240) | +| `GET /status` | none | `{version, protocol, mainGroup, owner: {owners, link?}, sessions: [{number, name, attached}]}` | [daemon.ts:328-331](../src/daemon.ts#L328-L331) | +| `POST /reset-owners` | none | `{link}` | [daemon.ts:110](../src/daemon.ts#L110) | +| `POST /stop` | none | `{}` after the stop sequence; the process exits after the reply | [daemon.ts:111-114](../src/daemon.ts#L111-L114) | | Status | Error | Condition | |---|---|---| | 401 | `Unauthorized` | token missing or wrong | | 404 | `Not found` | other method or path | | 400 | `Invalid JSON` | body not JSON | +| 400 | `Not a Claude Code session ID: ID` | `/session`, `/title`, `/attach` or `/call` with an ID that is not a UUID | | 409 | `Unknown session; restart the Claude Code session.` | `/attach` or `/call` for an ID without a folder | | 413 | `Body too large` | body over 1 MiB | -| 503 | `The SimpleX daemon is stopping.` | a new entry while the daemon stops | +| 503 | `The SimpleX daemon is stopping.` | `/session`, `/title`, `/attach` or `/call` while the daemon stops | | 500 | the error message | any other error | -`/session` applies `config` to the hub: mode and expiry for all sessions, and a rename when `mainGroup` differs ([hub.ts:63-71](../src/hub.ts#L63-L71)). +`/session` and `/title` apply `config` to the hub: mode and expiry for all sessions, and a rename when `mainGroup` differs ([hub.ts:63-71](../src/hub.ts#L63-L71)). + +`/title` with a non-blank name renames an open or opening profile, writes the name to the `session.json` of a stored session, or remembers it in memory for an ID without one, at most 100 IDs, the oldest dropped. It creates no entry, folder or number, and leaves the idle timers alone. A new session's `/session` without a name takes the remembered title, and the daemon forgets it. --- @@ -445,14 +449,14 @@ Environment: `SIMPLEX_MCP_HOME` (data root), `CLAUDE_CODE_SESSION_ID` and `CLAUD | Code | Message | Code location | |---|---|---| -| 0 | the help text on stdout; the hook, also after errors; `link` and `reset-owners`; the daemon after a stop or with `running` | [cli.ts:42-57](../src/cli.ts#L42-L57), [daemon.ts:293](../src/daemon.ts#L293), [:322](../src/daemon.ts#L322) | +| 0 | the help text on stdout; the hook, also after errors; `link` and `reset-owners`; the daemon after a stop or with `running` | [cli.ts:42-57](../src/cli.ts#L42-L57), [daemon.ts:342](../src/daemon.ts#L342), [:371](../src/daemon.ts#L371) | | 2 | `Unknown command. Run simplex-mcp --help.` | [cli.ts:36](../src/cli.ts#L36) | | 2 | `simplex-mcp session runs from the Claude Code plugin.` | [session.ts:36](../src/session.ts#L36) | | 2 | `Not a Claude Code session ID: ID` | [session.ts:37](../src/session.ts#L37) | | 1 | `Restart Claude Code to update the SimpleX plugin.` (`link`, `reset-owners`) | [client.ts:42](../src/client.ts#L42) | | 1 | `The SimpleX daemon exited; see ROOT/daemon.log`, `The SimpleX daemon did not start in 5 minutes.`, or the daemon's error text (`link`, `reset-owners`) | [client.ts:81](../src/client.ts#L81), [:108](../src/client.ts#L108) | -| 1 | daemon start failed: ready line `error: TEXT` | [daemon.ts:294-295](../src/daemon.ts#L294-L295) | -| 1 | `stop: ERROR` for a failed stop after a signal | [daemon.ts:299-304](../src/daemon.ts#L299-L304) | +| 1 | daemon start failed: ready line `error: TEXT` | [daemon.ts:343-344](../src/daemon.ts#L343-L344) | +| 1 | `stop: ERROR` for a failed stop after a signal | [daemon.ts:348-353](../src/daemon.ts#L348-L353) | | 1 | `simplex-mcp: ERROR` for other errors | [cli.ts:73-74](../src/cli.ts#L73-L74) | ### 4.3 Daemon log @@ -461,7 +465,7 @@ Environment: `SIMPLEX_MCP_HOME` (data root), `CLAUDE_CODE_SESSION_ID` and `CLAUD | Moment | Line | Code | |---|---|---| -| start | `SimpleX MCP daemon VERSION, data folder ROOT` | [daemon.ts:297](../src/daemon.ts#L297) | +| start | `SimpleX MCP daemon VERSION, data folder ROOT` | [daemon.ts:346](../src/daemon.ts#L346) | | Main group created | `Main group "NAME" created.` | [control.ts:347](../src/control.ts#L347) | | no owners, members present | `No owners in the Main group.` | [control.ts:103](../src/control.ts#L103), [:163](../src/control.ts#L163) | | link opened | `Main group link (works once): LINK`
`Whoever joins by this link becomes an owner of "NAME" and approves the agents' actions.` | [control.ts:283-284](../src/control.ts#L283-L284) | @@ -473,7 +477,7 @@ Environment: `SIMPLEX_MCP_HOME` (data root), `CLAUDE_CODE_SESSION_ID` and `CLAUD | perform ended, not interrupted, mode `host` | `Action N: performed without approval (mode host).`, or `...; failed: ERROR.` | [actions.ts:308](../src/actions.ts#L308) | | rejected | `Action N rejected by NAME.` | [actions.ts:207](../src/actions.ts#L207) | | expired | `Action N expired.` | [actions.ts:177](../src/actions.ts#L177) | -| stop | `Cancelled K action.` or `Cancelled K actions.` when K > 0
`Stopped.` | [daemon.ts:125](../src/daemon.ts#L125), [:131](../src/daemon.ts#L131) | +| stop | `Cancelled K action.` or `Cancelled K actions.` when K > 0
`Stopped.` | [daemon.ts:128](../src/daemon.ts#L128), [:134](../src/daemon.ts#L134) | stderr: `CONTEXT: ERROR` from `logError` ([log.ts:26-28](../src/log.ts#L26-L28)), with CONTEXT one of `action N`, `action N card`, `Main group command`, `EVENT in the Main group`, `Main group rename`, `owner reset`, `EVENT event`, `METHOD PATH`, `delivery wait`, `close session`, `stop`. The library's download lines are written to stderr. The session process writes `SimpleX daemon: ERROR` to its own stderr when its background connection fails ([session.ts:72](../src/session.ts#L72)). @@ -510,7 +514,7 @@ A missing or blank variable takes the default. The error text is `Plugin option ### 5.3 SessionStart output -Hook input fields used: `session_id`, `session_title`, `cwd` ([hook.ts:13-17](../src/hook.ts#L13-L17)). Output ([hook.ts:29-48](../src/hook.ts#L29-L48)): +Hook input fields used: `session_id`, `session_title`, `cwd` ([hook.ts:13-17](../src/hook.ts#L13-L17)). The hook posts `/title`, and MAIN is the reply's `mainGroup`. Output ([hook.ts:29-47](../src/hook.ts#L29-L47)): | Case | Output | |---|---| diff --git a/packages/simplex-chat-mcp/spec/architecture.md b/packages/simplex-chat-mcp/spec/architecture.md index 9b63296489..7c99b04b6b 100644 --- a/packages/simplex-chat-mcp/spec/architecture.md +++ b/packages/simplex-chat-mcp/spec/architecture.md @@ -70,9 +70,9 @@ Data flow of a tool call: 1. Claude Code calls a tool of the MCP server `simplex` of the session; the SDK validates the arguments against the input schema ([`tools.ts:354-364`](../src/tools.ts#L354-L364), [`mcp-BumlNux0.cjs:1812-1817`](../node_modules/@modelcontextprotocol/server/dist/mcp-BumlNux0.cjs#L1812-L1817)). 2. The session process connects to the daemon when it has no connection: status and version check, spawn when not running, `POST /session`, then `POST /attach` held open ([`session.ts:123-140`](../src/session.ts#L123-L140), [`client.ts:32-44`](../src/client.ts#L32-L44)). 3. The session process sends `POST /call` with the session ID, the tool name and the input, relays each `{progress}` line as an MCP progress notification, and returns the `{result}` line ([`session.ts:94-113`](../src/session.ts#L94-L113)). -4. The daemon checks the token, finds or opens the session's agent, and runs `AgentTools.call` with an abort signal tied to the request ([`socket.ts:18-48`](../src/socket.ts#L18-L48), [`daemon.ts:187-204`](../src/daemon.ts#L187-L204)). +4. The daemon checks the token, finds or opens the session's agent, and runs `AgentTools.call` with an abort signal tied to the request ([`socket.ts:18-48`](../src/socket.ts#L18-L48), [`daemon.ts:223-240`](../src/daemon.ts#L223-L240)). 5. `AgentTools` sends chat commands to the session core; actions are created in the hub's `Actions` and shown to the owner by `Control` in the Main group ([`tools.ts:414-427`](../src/tools.ts#L414-L427), [`actions.ts:124-157`](../src/actions.ts#L124-L157)). -6. After an approval, `Actions` calls the daemon's `perform`, which runs the perform step in the action's session ([`daemon.ts:153-157`](../src/daemon.ts#L153-L157), [`tools.ts:378-387`](../src/tools.ts#L378-L387)). +6. After an approval, `Actions` calls the daemon's `perform`, which runs the perform step in the action's session ([`daemon.ts:156-160`](../src/daemon.ts#L156-L160), [`tools.ts:378-387`](../src/tools.ts#L378-L387)). 7. Each core emits its events: the hub core to `Control`, a session core to its item watch and connect actions ([`hub.ts:92-104`](../src/hub.ts#L92-L104), [`agent.ts:44-57`](../src/agent.ts#L44-L57)). --- @@ -127,26 +127,27 @@ Data flow of a tool call: | Symbol | Line | Contents | |---|---|---| | `protocolVersion` | [5](../src/protocol.ts#L5) | `1` | -| `SessionRequest`, `SessionReply` | [17-28](../src/protocol.ts#L17-L28) | `/session` | -| `AttachRequest`, `CallRequest`, `CallLine` | [30-40](../src/protocol.ts#L30-L40) | `/attach`, `/call` | -| `StatusReply`, `ResetReply` | [42-50](../src/protocol.ts#L42-L50) | `/status`, `/reset-owners` | +| `SessionRequest`, `TitleRequest`, `TitleReply`, `SessionReply` | [17-35](../src/protocol.ts#L17-L35) | `/session`, `/title` | +| `AttachRequest`, `CallRequest`, `CallLine` | [37-47](../src/protocol.ts#L37-L47) | `/attach`, `/call` | +| `StatusReply`, `ResetReply` | [49-57](../src/protocol.ts#L49-L57) | `/status`, `/reset-owners` | ### [2.6 `daemon.ts`](../src/daemon.ts) | Symbol | Line | Contents | |---|---|---| -| `HubLike`, `AgentLike`, `DaemonDeps` | [22-53](../src/daemon.ts#L22-L53) | interfaces the daemon uses; tests replace the cores | -| `Entry` | [55](../src/daemon.ts#L55) | opening promise, agent, open attaches, running calls, idle timer | -| `Daemon.start` | [77](../src/daemon.ts#L77) | lock, session numbers, hub, listener, stop timer | -| `Daemon.routes` | [101](../src/daemon.ts#L101) | the six routes | -| `Daemon.shutdown` | [115](../src/daemon.ts#L115) | stop sequence | -| `Daemon.perform` | [153](../src/daemon.ts#L153) | perform step in the action's session | -| `onSession`, `onAttach`, `onCall` | [159-204](../src/daemon.ts#L159-L204) | route handlers | -| `Daemon.entry`, `openAgent` | [217-249](../src/daemon.ts#L217-L249) | session registry; new session number and name | -| `scheduleIdle`, `closeAgent`, `scheduleStop` | [251-277](../src/daemon.ts#L251-L277) | idle close of a session, idle stop of the daemon | -| `runDaemon` | [285](../src/daemon.ts#L285) | process entry: data folder, start, ready line, signal handlers | -| `productionDeps` | [309](../src/daemon.ts#L309) | token, `Hub.open`, `openSessionAgent` | -| `readyChannel` | [326](../src/daemon.ts#L326) | ready line to fd `SIMPLEX_MCP_READY_FD`, or stdout | +| `HubLike`, `AgentLike`, `DaemonDeps` | [23-54](../src/daemon.ts#L23-L54) | interfaces the daemon uses; tests replace the cores | +| `Entry` | [56](../src/daemon.ts#L56) | opening promise, agent, open attaches, running calls, idle timer | +| `Daemon.start` | [79](../src/daemon.ts#L79) | lock, session numbers, hub, listener, stop timer | +| `Daemon.routes` | [103](../src/daemon.ts#L103) | the seven routes | +| `Daemon.shutdown` | [118](../src/daemon.ts#L118) | stop sequence | +| `Daemon.perform` | [156](../src/daemon.ts#L156) | perform step in the action's session | +| `onSession`, `onTitle`, `onAttach`, `onCall` | [162-240](../src/daemon.ts#L162-L240) | route handlers | +| `applyTitle`, `rename`, `rememberTitle`, `takeTitle` | [181-208](../src/daemon.ts#L181-L208) | title of an open, a stored or an unknown session; remembered titles, at most 100 | +| `Daemon.entry`, `refuseWhenStopping`, `folder`, `openAgent` | [253-298](../src/daemon.ts#L253-L298) | session registry; status 503 while stopping, 400 for an ID that is not a session ID; new session number and name | +| `scheduleIdle`, `closeAgent`, `scheduleStop` | [300-326](../src/daemon.ts#L300-L326) | idle close of a session, idle stop of the daemon | +| `runDaemon` | [334](../src/daemon.ts#L334) | process entry: data folder, start, ready line, signal handlers | +| `productionDeps` | [358](../src/daemon.ts#L358) | token, `Hub.open`, `openSessionAgent` | +| `readyChannel` | [375](../src/daemon.ts#L375) | ready line to fd `SIMPLEX_MCP_READY_FD`, or stdout | ### [2.7 `hub.ts`](../src/hub.ts) @@ -269,7 +270,7 @@ Data flow of a tool call: | Symbol | Line | Contents | |---|---|---| -| `sessionStart` | [hook.ts:29](../src/hook.ts#L29) | Node.js check, options, `ensureDaemon`, `/session`, link output; every failure as `systemMessage` | +| `sessionStart` | [hook.ts:29](../src/hook.ts#L29) | Node.js check, options, `ensureDaemon`, `/title`, link output; every failure as `systemMessage` | | `linkText`, `resetOwnersText` | [owner.ts:7-15](../src/owner.ts#L7-L15) | owners, or the link with a QR code on a terminal | | `showLink`, `resetOwners` | [owner.ts:17-26](../src/owner.ts#L17-L26) | `ensureDaemon`, then `/status` data or `/reset-owners` | | `qrCodeText` | [qr.ts:12](../src/qr.ts#L12) | error correction level L, margin 2, two modules per text line; `ansi` colors or `plain` block characters | @@ -298,15 +299,16 @@ Data flow of a tool call: | Item | Holder | Written by | Read by | |---|---|---|---| -| Open sessions | `Daemon.entries` by session ID ([daemon.ts:64](../src/daemon.ts#L64)) | `entry`, idle close | routes, `perform`, `Sessions` of `Control` | -| Next session number | `Daemon.nextNumber` ([daemon.ts:74](../src/daemon.ts#L74)) | constructor: largest number in `_sessions` plus 1; `openAgent` | `openAgent` | +| Open sessions | `Daemon.entries` by session ID ([daemon.ts:65](../src/daemon.ts#L65)) | `entry`, idle close | routes, `perform`, `Sessions` of `Control` | +| Remembered titles | `Daemon.titles` by lowercased session ID, at most 100 ([daemon.ts:68](../src/daemon.ts#L68)) | `/title` for an ID without `session.json` | `openAgent` of a new session, which removes the entry | +| Next session number | `Daemon.nextNumber` ([daemon.ts:76](../src/daemon.ts#L76)) | constructor: largest number in `_sessions` plus 1; `openAgent` | `openAgent` | | Actions of all sessions | `Actions.actions` map ([actions.ts:101](../src/actions.ts#L101)) | `create`, `remove`, `expireDue` | tools, `Control`, idle check | -| Mode and expiry hours | `Actions.mode`, `Actions.expireHours` ([actions.ts:106](../src/actions.ts#L106)) | `Hub.open`, `Hub.configure` at each `/session` | `create`, cards, `standingApprover` | +| Mode and expiry hours | `Actions.mode`, `Actions.expireHours` ([actions.ts:106](../src/actions.ts#L106)) | `Hub.open`, `Hub.configure` at each `/session` and `/title` | `create`, cards, `standingApprover` | | Open calls of an action | `ActionRecord.open` ([actions.ts:44](../src/actions.ts#L44)) | `actionResult` ([tools.ts:434](../src/tools.ts#L434), [:442](../src/tools.ts#L442)) | `list`, `expireDue` | | Collected replies | `ConnectAction.messages`, `SendAction.responses` ([actions.ts:55](../src/actions.ts#L55), [:73](../src/actions.ts#L73)) | `ChatItemsWatch.collect` | `connect`, `send` results | | Item listeners | one `ChatItemsWatch` per session ([agent.ts:42](../src/agent.ts#L42)) | `collect` | the session's event dispatch | | Main group members | `Control.members` ([control.ts:66](../src/control.ts#L66)) | `refreshMembers` ([:302](../src/control.ts#L302)) | owner checks | -| One-time link | `Control.linkText` ([control.ts:70](../src/control.ts#L70)) | `openLink`, `deleteLink` | `/session`, `/status`, refusals | +| One-time link | `Control.linkText` ([control.ts:70](../src/control.ts#L70)) | `openLink`, `deleteLink` | `/session`, `/title`, `/status`, refusals | The session process keeps only its connection state: the connect promise and the `/attach` request ([session.ts:55-60](../src/session.ts#L55-L60)). @@ -332,7 +334,7 @@ ROOT is `SIMPLEX_MCP_HOME`, resolved to an absolute path, or `~/.simplex-mcp` ([ | `ROOT/daemon.log` | stdout and stderr of the daemon | 0600 | spawner, truncated when over 10 MB ([files.ts:68-74](../src/files.ts#L68-L74)) | people | | `ROOT/_hub/` | hub databases | 0700 | `openCore` ([core.ts:22](../src/core.ts#L22)) | hub core | | `ROOT/_sessions/ID/` | session databases | 0700 | `openAgent`, `openCore` | session core | -| `ROOT/_sessions/ID/session.json` | `{number, name, project, created}` | 0600 | new session, rename ([daemon.ts:167-170](../src/daemon.ts#L167-L170), [:244-246](../src/daemon.ts#L244-L246)) | `openAgent`, `sessionNumbers` | +| `ROOT/_sessions/ID/session.json` | `{number, name, project, created}` | 0600 | new session, rename, title of a stored session ([daemon.ts:186-194](../src/daemon.ts#L186-L194), [:293-295](../src/daemon.ts#L293-L295)) | `openAgent`, `sessionNumbers` | The socket path is `ROOT/daemon.sock` when it fits in 103 bytes, else `TMP/simplex-mcp-H.sock`; on Windows it is `\\.\pipe\simplex-mcp-H`. H is the first 16 hex digits of the SHA-256 of ROOT ([files.ts:57-62](../src/files.ts#L57-L62)). @@ -344,13 +346,13 @@ The core library is downloaded once into `CACHE/core-/sqlite`, and the ### 4.1 SessionStart hook -`node plugin/launch.js hook session-start` runs `npx -y @simplex-chat/mcp@VERSION hook session-start`; `sessionStart` reads the hook input from stdin ([cli.ts:49-51](../src/cli.ts#L49-L51), [hook.ts:29-48](../src/hook.ts#L29-L48)): +`node plugin/launch.js hook session-start` runs `npx -y @simplex-chat/mcp@VERSION hook session-start`; `sessionStart` reads the hook input from stdin ([cli.ts:49-51](../src/cli.ts#L49-L51), [hook.ts:29-47](../src/hook.ts#L29-L47)): 1. Node.js major version below 22: `systemMessage` `SimpleX MCP needs Node.js 22 or later; found V.` 2. Parse `{session_id, session_title, cwd}`, read the options; a `ConfigError` becomes the `systemMessage`. 3. `ensureDaemon` ([4.3](#43-daemon-spawn-and-version-check)). -4. `POST /session` with `{id: session_id, name: session_title, project: CLAUDE_PROJECT_DIR or cwd, config}`. -5. Owners present, or no link: empty output. Otherwise a `systemMessage` with the link and a `plain` QR code, and `additionalContext` with the link. +4. `POST /title` with `{id: session_id, name: session_title, project: CLAUDE_PROJECT_DIR or cwd, config}`. The hook records the title and opens no profile; the session process creates the profile ([4.5](#45-session-profiles)). +5. Owners present, or no link: empty output. Otherwise a `systemMessage` with the link of the reply's Main group and a `plain` QR code, and `additionalContext` with the link. 6. Any error: `systemMessage` `SimpleX MCP: TEXT`; a not-running error reads `The SimpleX daemon stopped; it starts again with the next tool call.` The exit code is 0. ### 4.2 Session process @@ -392,7 +394,7 @@ The daemon inherits the spawner's environment, so its options are those of the s ### 4.4 Daemon start -`runDaemon` ([daemon.ts:285-307](../src/daemon.ts#L285-L307)), `Daemon.start` ([:77-94](../src/daemon.ts#L77-L94)): +`runDaemon` ([daemon.ts:334-356](../src/daemon.ts#L334-L356)), `Daemon.start` ([:79-96](../src/daemon.ts#L79-L96)): 1. `createDataFolder(ROOT)`; the token is read or created. 2. The lock is taken; when another daemon holds it, the ready line is `running` and the process ends. @@ -412,13 +414,16 @@ An error after the lock releases it; the ready line is `error: TEXT` and the exi ### 4.5 Session profiles -`Daemon.entry` and `openAgent` ([daemon.ts:217-249](../src/daemon.ts#L217-L249)): +`Daemon.entry` and `openAgent` ([daemon.ts:253-298](../src/daemon.ts#L253-L298)): - The first `/session`, `/attach` or `/call` for an ID creates its entry; a close of the same ID still in progress is awaited first. -- A folder without `session.json`: with a `/session` request, the folder is created and `{number, name, project, created}` written, with the next number and `sessionName(name, project)`; without it, status 409 `Unknown session; restart the Claude Code session.` +- An ID that is not a session ID: status 400 `Not a Claude Code session ID: ID`, and no entry. +- A folder without `session.json`: with a `/session` request, the folder is created and `{number, name, project, created}` written, with the next number and `sessionName(name, project)`, where the name is the request's, else the title remembered for the ID, which is then forgotten; without it, status 409 `Unknown session; restart the Claude Code session.` - `openSessionAgent` opens the core with `profileName(name)` and dispatches its events ([agent.ts:39-72](../src/agent.ts#L39-L72)). - A failed open removes the entry. -- `/session` with a name other than the stored one renames the profile and updates `session.json` ([daemon.ts:166-170](../src/daemon.ts#L166-L170)); every `/session` applies its options to the hub ([:163](../src/daemon.ts#L163)). +- `/session` with a name other than the stored one renames the profile and updates `session.json` ([daemon.ts:167-168](../src/daemon.ts#L167-L168), [:189-194](../src/daemon.ts#L189-L194)); every `/session` applies its options to the hub ([:166](../src/daemon.ts#L166)). +- `/title` applies its options to the hub, and with a non-blank name: an open or opening profile is renamed as by `/session`; a stored session gets the name in `session.json`, and its profile takes it at the next open; for an ID without `session.json` the name is remembered in memory, at most 100 IDs, the oldest dropped. `/title` creates no entry, folder or number, opens no profile, and leaves the idle timers alone ([daemon.ts:172-208](../src/daemon.ts#L172-L208)). +- After `/clear`, and after `--continue` or `--resume` without an ID, Claude Code gives the hook a new session ID while the session process keeps its own, so the title of the new ID stays remembered until it is dropped. ### 4.6 Event dispatch @@ -448,14 +453,14 @@ Owner commands are queued in a chain of their own, independent of the membership | Timer | Start | Fires | Code | |---|---|---|---| -| session idle, 10 min | entry created; last `/attach` closed; last call ended with no attach; `/session` with no attach | closes the profile when it has no running call and no action; otherwise starts again | [daemon.ts:251-261](../src/daemon.ts#L251-L261) | -| daemon idle, 10 min | daemon start; last entry removed | stops the daemon | [daemon.ts:271-277](../src/daemon.ts#L271-L277) | +| session idle, 10 min | entry created; last `/attach` closed; last call ended with no attach; `/session` with no attach | closes the profile when it has no running call and no action; otherwise starts again | [daemon.ts:300-310](../src/daemon.ts#L300-L310) | +| daemon idle, 10 min | daemon start; last entry removed | stops the daemon | [daemon.ts:320-326](../src/daemon.ts#L320-L326) | -An open `/attach` clears the session's timer ([daemon.ts:179](../src/daemon.ts#L179)); a new entry clears the daemon's ([:221](../src/daemon.ts#L221)). +An open `/attach` clears the session's timer ([daemon.ts:215](../src/daemon.ts#L215)); a new entry clears the daemon's ([:258](../src/daemon.ts#L258)). ### 4.8 Stop -`POST /stop`, SIGINT, SIGTERM or the idle timer run one shared stop ([daemon.ts:96-99](../src/daemon.ts#L96-L99), [:115-139](../src/daemon.ts#L115-L139)): +`POST /stop`, SIGINT, SIGTERM or the idle timer run one shared stop ([daemon.ts:98-101](../src/daemon.ts#L98-L101), [:118-142](../src/daemon.ts#L118-L142)): 1. Timers are cleared; new entries are refused with status 503 `The SimpleX daemon is stopping.` 2. The listener is closed; open `/attach` and `/call` streams are destroyed, which drops the session processes' connections. @@ -479,7 +484,7 @@ acquireLock(ROOT): files.ts:81 releaseLock(ROOT): remove ROOT/lock, ENOENT ignored :90-96 ``` -`runDaemon` turns `LockedError` into the ready line `running` ([daemon.ts:293](../src/daemon.ts#L293)). +`runDaemon` turns `LockedError` into the ready line `running` ([daemon.ts:342](../src/daemon.ts#L342)). --- @@ -492,7 +497,7 @@ releaseLock(ROOT): remove ROOT/lock, ENOENT ignored :90-96 | `requests` | `pending`
`performing` for a send under a standing approval | card with commands
outcome card after a standing send | none | | `host` | `performing` | outcome card after the perform | `{"anthropic/requiresUserInteraction": true}` | -The daemon takes the mode and the expiry of the latest `/session` for all sessions ([hub.ts:63-66](../src/hub.ts#L63-L66)); each session process sets `_meta` from its own options at its start ([session.ts:48](../src/session.ts#L48), [tools.ts:360](../src/tools.ts#L360)). Sources: [actions.ts:136](../src/actions.ts#L136). In both modes a new action is refused unless the Main group has an owner ([control.ts:113-114](../src/control.ts#L113-L114)). +The daemon takes the mode and the expiry of the latest `/session` or `/title` for all sessions ([hub.ts:63-66](../src/hub.ts#L63-L66)); each session process sets `_meta` from its own options at its start ([session.ts:48](../src/session.ts#L48), [tools.ts:360](../src/tools.ts#L360)). Sources: [actions.ts:136](../src/actions.ts#L136). In both modes a new action is refused unless the Main group has an owner ([control.ts:113-114](../src/control.ts#L113-L114)). ### 5.2 Owners @@ -615,7 +620,7 @@ Checks in order of the request listener ([socket.ts:24-36](../src/socket.ts#L24- | session ID | UUID format, before it becomes a path | 500 `Not a Claude Code session ID: ID` | [files.ts:34-37](../src/files.ts#L34-L37) | | handler error | `SocketError` status, else 500 and a log line; after headers were sent, the connection is destroyed | | [socket.ts:30-35](../src/socket.ts#L30-L35) | -Node limits: `requestTimeout` 0, `headersTimeout` 60 s, `keepAliveTimeout` 5 s ([socket.ts:37-39](../src/socket.ts#L37-L39)). A stale socket file is removed before listening, except on Windows ([:40](../src/socket.ts#L40)). Clients use no connection pool ([socket.ts:96-97](../src/socket.ts#L96-L97)). When the client closes a `/call` stream before its end, the call's signal is aborted ([daemon.ts:192-194](../src/daemon.ts#L192-L194)); the session process closes it when the MCP request is aborted ([session.ts:99-103](../src/session.ts#L99-L103)). +Node limits: `requestTimeout` 0, `headersTimeout` 60 s, `keepAliveTimeout` 5 s ([socket.ts:37-39](../src/socket.ts#L37-L39)). A stale socket file is removed before listening, except on Windows ([:40](../src/socket.ts#L40)). Clients use no connection pool ([socket.ts:96-97](../src/socket.ts#L96-L97)). When the client closes a `/call` stream before its end, the call's signal is aborted ([daemon.ts:228-230](../src/daemon.ts#L228-L230)); the session process closes it when the MCP request is aborted ([session.ts:99-103](../src/session.ts#L99-L103)). ### 6.2 Token @@ -652,7 +657,7 @@ The daemon writes the tappable form `/'...'` only in the card's command line, `/ | owner commands: `S:CHAT` with S a positive number and CHAT a chat reference | [control.ts:527-530](../src/control.ts#L527-L530) | | a store error of the chat read is reported as `No chat CHAT.` | [tools.ts:661-663](../src/tools.ts#L661-L663), [:707-714](../src/tools.ts#L707-L714) | | the agent's own links are refused by `connect` (`ownLink` plans) | [tools.ts:829](../src/tools.ts#L829), [:847](../src/tools.ts#L847), [:869](../src/tools.ts#L869) | -| a session reaches only its own core: its chats, its actions in `list`, its key space | [daemon.ts:187-198](../src/daemon.ts#L187-L198), [tools.ts:697](../src/tools.ts#L697) | +| a session reaches only its own core: its chats, its actions in `list`, its key space | [daemon.ts:223-234](../src/daemon.ts#L223-L234), [tools.ts:697](../src/tools.ts#L697) | The Main group belongs to the hub core, so no session's tools can reach it. @@ -668,7 +673,7 @@ The Main group belongs to the hub core, so no session's tools can reach it. ### 6.7 Link and network -- The join link is a group link of the Main group with role `member`; it is logged to `daemon.log`, returned by `/session` and `/status`, and deleted at the first join of a human while no owner exists ([control.ts:280-295](../src/control.ts#L280-L295), [:267-273](../src/control.ts#L267-L273)). +- The join link is a group link of the Main group with role `member`; it is logged to `daemon.log`, returned by `/session`, `/title` and `/status`, and deleted at the first join of a human while no owner exists ([control.ts:280-295](../src/control.ts#L280-L295), [:267-273](../src/control.ts#L267-L273)). - Every core sets private message routing: `smp-proxy=always`, `smp-proxy-fallback=protected`, `socks=off` ([core.ts:11](../src/core.ts#L11), [:26](../src/core.ts#L26)). - With `incognito: true` the daemon sends `incognito=on` with the connect command ([tools.ts:517](../src/tools.ts#L517)); the core then uses a random profile for that chat. diff --git a/packages/simplex-chat-mcp/spec/flows.md b/packages/simplex-chat-mcp/spec/flows.md index ebd5c293b5..1b855432d9 100644 --- a/packages/simplex-chat-mcp/spec/flows.md +++ b/packages/simplex-chat-mcp/spec/flows.md @@ -68,11 +68,10 @@ sequenceDiagram D->>D: listen on daemon.sock, idle stop timer D->>K: fd 3: ready K->>D: GET /status - K->>D: POST /session {id, name, project, config} - D->>D: _sessions/ID/session.json {number: 1, name} - D->>D: open core "SimpleX MCP - NAME" + K->>D: POST /title {id, name, project, config} D->>H: configure(config) - D-->>K: {number: 1, displayName, owner: {owners: [], link}} + D->>D: no entry, no session.json: remember NAME for ID + D-->>K: {mainGroup, owner: {owners: [], link}, version, protocol} K-->>C: systemMessage: QR code and link; additionalContext: link O->>H: joins by the link H->>H: joinedGroupMember: /_delete link #G, /_member role #G M owner @@ -83,12 +82,13 @@ sequenceDiagram Notes: - At first use, libsimplex and the Node.js add-on are downloaded, with one `Downloading ...` line each in `daemon.log`; the first `npx` run also fills the npx cache. The hook's timeout is 600 s, and the spawner waits for the ready line at most 5 minutes ([hooks.json](../hooks/hooks.json), [client.ts:108](../src/client.ts#L108)). -- The project is `CLAUDE_PROJECT_DIR`, else the hook's `cwd`; the profile name is `SimpleX MCP - ` and the session title, else the project folder name, else `Claude Code`, through `validName` ([hook.ts:37](../src/hook.ts#L37), [agent.ts:29-37](../src/agent.ts#L29-L37)). +- The hook records the title and creates no profile, folder or number; the session process creates the profile ([2](#2-session-process-start-and-attach)). When the session process reaches the daemon first, `/title` renames its profile instead ([daemon.ts:172-194](../src/daemon.ts#L172-L194)). +- The profile name is `SimpleX MCP - ` and the session title, else the project folder name, else `Claude Code`, through `validName` ([agent.ts:29-37](../src/agent.ts#L29-L37)). - The QR code in the hook message is `plain`: block characters without colors ([qr.ts:27-31](../src/qr.ts#L27-L31)). - With an owner present, the hook output is `{}`. - The daemon runs `ownerJoined` for a `joinedGroupMember` event of a human while no owner exists ([control.ts:161](../src/control.ts#L161), [:267-273](../src/control.ts#L267-L273)). -Source: [hook.ts:29-48](../src/hook.ts#L29-L48), [client.ts:32-89](../src/client.ts#L32-L89), [daemon.ts:77-94](../src/daemon.ts#L77-L94), [:285-307](../src/daemon.ts#L285-L307), [hub.ts:26-48](../src/hub.ts#L26-L48), [core.ts:21-35](../src/core.ts#L21-L35), [control.ts:92-105](../src/control.ts#L92-L105), [:280-285](../src/control.ts#L280-L285), [:338-349](../src/control.ts#L338-L349), [daemon.ts:159-172](../src/daemon.ts#L159-L172), [:238-249](../src/daemon.ts#L238-L249). +Source: [hook.ts:29-47](../src/hook.ts#L29-L47), [client.ts:32-89](../src/client.ts#L32-L89), [daemon.ts:79-96](../src/daemon.ts#L79-L96), [:334-356](../src/daemon.ts#L334-L356), [hub.ts:26-48](../src/hub.ts#L26-L48), [core.ts:21-35](../src/core.ts#L21-L35), [control.ts:92-105](../src/control.ts#L92-L105), [:280-285](../src/control.ts#L280-L285), [:338-349](../src/control.ts#L338-L349), [daemon.ts:172-208](../src/daemon.ts#L172-L208). --- @@ -105,6 +105,8 @@ sequenceDiagram par background connect S->>D: GET /status, version and protocol check S->>D: POST /session {id, project, config} + D->>D: _sessions/ID/session.json {number: 1, name: remembered NAME} + D->>D: open core "SimpleX MCP - NAME" D-->>S: {number, displayName, owner} S->>D: POST /attach {id} D-->>S: {version, protocol}, stream held open @@ -119,10 +121,11 @@ Notes: - Tools are registered before the daemon is reached, so the MCP startup does not wait for the daemon; a tool call before the connection is ready awaits the same connect promise ([session.ts:47-52](../src/session.ts#L47-L52), [:62-68](../src/session.ts#L62-L68)). - A session started with an invalid option does not connect; every tool call returns the option error ([session.ts:38-48](../src/session.ts#L38-L48)). -- The session process sends `/session` without a name, so the profile keeps the name from the hook ([session.ts:46](../src/session.ts#L46), [daemon.ts:166-170](../src/daemon.ts#L166-L170)). +- The session process sends `/session` without a name. A new session takes the title the hook recorded for its ID, else the project folder name; a stored session keeps its name ([session.ts:46](../src/session.ts#L46), [daemon.ts:287-298](../src/daemon.ts#L287-L298)). +- After `/clear`, and after `--continue` or `--resume` without an ID, the hook gets a new session ID while the session process keeps its own; the daemon remembers the new ID's title in memory only, at most 100 IDs, so no profile is created for it. - `claude --resume` starts a session process with the same session ID: the daemon reopens `_sessions/ID/` with the same number and chats. -Source: [session.ts:34-53](../src/session.ts#L34-L53), [:123-140](../src/session.ts#L123-L140), [daemon.ts:174-185](../src/daemon.ts#L174-L185). +Source: [session.ts:34-53](../src/session.ts#L34-L53), [:123-140](../src/session.ts#L123-L140), [daemon.ts:162-170](../src/daemon.ts#L162-L170), [:210-221](../src/daemon.ts#L210-L221), [:287-298](../src/daemon.ts#L287-L298). --- @@ -182,7 +185,7 @@ Notes: - For a target the session is connected to, the result is `status: known` with its chat fields, and no action is created ([tools.ts:419](../src/tools.ts#L419)). - Exact progress texts: `Action N waits for an approver until TIME.`, then `Action N waits for the connection until TIME.`, then `K messages in CHAT; waiting until TIME.` ([actions.ts:315-326](../src/actions.ts#L315-L326), [tools.ts:454](../src/tools.ts#L454)). -Source: [session.ts:94-113](../src/session.ts#L94-L113), [daemon.ts:187-204](../src/daemon.ts#L187-L204), [tools.ts:414-466](../src/tools.ts#L414-L466), [:482-572](../src/tools.ts#L482-L572), [:810-873](../src/tools.ts#L810-L873), [actions.ts:124-202](../src/actions.ts#L124-L202), [control.ts:138-198](../src/control.ts#L138-L198). +Source: [session.ts:94-113](../src/session.ts#L94-L113), [daemon.ts:223-240](../src/daemon.ts#L223-L240), [tools.ts:414-466](../src/tools.ts#L414-L466), [:482-572](../src/tools.ts#L482-L572), [:810-873](../src/tools.ts#L810-L873), [actions.ts:124-202](../src/actions.ts#L124-L202), [control.ts:138-198](../src/control.ts#L138-L198). --- @@ -349,7 +352,7 @@ sequenceDiagram Notes: -- `_meta` follows the option of the session's own start; the daemon's mode follows the latest `/session` of any session ([session.ts:48](../src/session.ts#L48), [hub.ts:63-66](../src/hub.ts#L63-L66)). +- `_meta` follows the option of the session's own start; the daemon's mode follows the latest `/session` or `/title` of any session ([session.ts:48](../src/session.ts#L48), [hub.ts:63-66](../src/hub.ts#L63-L66)). - New actions are still refused unless the Main group has an owner ([control.ts:113-114](../src/control.ts#L113-L114)). - In mode `host`, `undefined` is returned by `standingApprover`, so standing approvals are ignored by `send`, `list` and `/list` ([control.ts:119](../src/control.ts#L119)). - `_meta` is set on `connect`, `send` and `leave` only ([tools.ts:360](../src/tools.ts#L360)). @@ -383,7 +386,7 @@ Notes: - A decided action with an open count of 0 is removed at the first tick after its expiry time ([actions.ts:179-181](../src/actions.ts#L179-L181)). - Expiry times are listed in the `/pending` reply ([control.ts:437-439](../src/control.ts#L437-L439)). -- A changed `expire_hours` applies to actions created after the next `/session` ([hub.ts:65](../src/hub.ts#L65), [actions.ts:133](../src/actions.ts#L133)). +- A changed `expire_hours` applies to actions created after the next `/session` or `/title` ([hub.ts:65](../src/hub.ts#L65), [actions.ts:133](../src/actions.ts#L133)). - The connect wait ends at `created + 23 h 50 min`, within the 24 hours in which a message can be edited ([tools.ts:112](../src/tools.ts#L112), [:485](../src/tools.ts#L485)). Source: [actions.ts:102](../src/actions.ts#L102), [:172-183](../src/actions.ts#L172-L183), [control.ts:169](../src/control.ts#L169), [:397-398](../src/control.ts#L397-L398). @@ -421,7 +424,7 @@ Notes: - The key is the session number, the tool name and the action fields: `target`, `incognito`, `message` for `connect`; `chat`, `text`, `reply_to`, `mention` for `send`; `chat` for `leave` ([tools.ts:415](../src/tools.ts#L415), [:584](../src/tools.ts#L584), [:635](../src/tools.ts#L635), [:987-989](../src/tools.ts#L987-L989)). - The `wait`, `idle` and `limit` of the attaching call are used ([tools.ts:595](../src/tools.ts#L595)). - For a call aborted before `create`, no action is created ([tools.ts:424](../src/tools.ts#L424), [:587](../src/tools.ts#L587), [:639](../src/tools.ts#L639)). -- Abort path: the SDK aborts the handler's signal; the session process passes it to its `/call` request; the daemon aborts the call's controller when the request closes before the reply ends, and writes no result ([session.ts:99-103](../src/session.ts#L99-L103), [daemon.ts:191-199](../src/daemon.ts#L191-L199)). +- Abort path: the SDK aborts the handler's signal; the session process passes it to its `/call` request; the daemon aborts the call's controller when the request closes before the reply ends, and writes no result ([session.ts:99-103](../src/session.ts#L99-L103), [daemon.ts:227-235](../src/daemon.ts#L227-L235)). Source: [tools.ts:433-444](../src/tools.ts#L433-L444), [:592-610](../src/tools.ts#L592-L610), [:697](../src/tools.ts#L697), [actions.ts:185-196](../src/actions.ts#L185-L196), [messages.ts:208-222](../src/messages.ts#L208-L222). @@ -448,10 +451,10 @@ sequenceDiagram Notes: -- A new `/session`, `/attach` or `/call` for any ID clears the daemon idle timer; a new request for a closing ID waits for the close and reopens the profile ([daemon.ts:217-249](../src/daemon.ts#L217-L249)). -- The daemon idle timer also runs from the daemon start until the first entry ([daemon.ts:88](../src/daemon.ts#L88)). +- A new `/session`, `/attach` or `/call` for any ID clears the daemon idle timer; a new request for a closing ID waits for the close and reopens the profile ([daemon.ts:253-298](../src/daemon.ts#L253-L298)). `/title` leaves both idle timers alone. +- The daemon idle timer also runs from the daemon start until the first entry ([daemon.ts:90](../src/daemon.ts#L90)). -Source: [daemon.ts:174-185](../src/daemon.ts#L174-L185), [:251-277](../src/daemon.ts#L251-L277). +Source: [daemon.ts:210-221](../src/daemon.ts#L210-L221), [:300-326](../src/daemon.ts#L300-L326). --- @@ -489,7 +492,7 @@ Notes: - The perform signal is aborted after the actions are marked interrupted: a `connect` waiting for its connection ends, and an interrupted perform logs no line ([actions.ts:225](../src/actions.ts#L225), [:259](../src/actions.ts#L259), [tools.ts:509-511](../src/tools.ts#L509-L511)). - An error of a `/sql` query of the delivery wait is logged as `delivery wait: ERROR` and ends the wait ([core.ts:54-62](../src/core.ts#L54-L62)). -Source: [daemon.ts:96-139](../src/daemon.ts#L96-L139), [:299-306](../src/daemon.ts#L299-L306), [hub.ts:77-84](../src/hub.ts#L77-L84), [actions.ts:219-229](../src/actions.ts#L219-L229), [core.ts:44-52](../src/core.ts#L44-L52). +Source: [daemon.ts:98-142](../src/daemon.ts#L98-L142), [:348-355](../src/daemon.ts#L348-L355), [hub.ts:77-84](../src/hub.ts#L77-L84), [actions.ts:219-229](../src/actions.ts#L219-L229), [core.ts:44-52](../src/core.ts#L44-L52). --- @@ -546,7 +549,7 @@ Notes: - Versions are compared by their first three numeric parts ([client.ts:117-122](../src/client.ts#L117-L122)). - Session processes of version A lose their streams when A stops; their next call connects to B, whose protocol must match. -Source: [client.ts:32-62](../src/client.ts#L32-L62), [daemon.ts:108-111](../src/daemon.ts#L108-L111). +Source: [client.ts:32-62](../src/client.ts#L32-L62), [daemon.ts:111-114](../src/daemon.ts#L111-L114). --- diff --git a/packages/simplex-chat-mcp/src/daemon.ts b/packages/simplex-chat-mcp/src/daemon.ts index 902be335eb..6bd58c1aee 100644 --- a/packages/simplex-chat-mcp/src/daemon.ts +++ b/packages/simplex-chat-mcp/src/daemon.ts @@ -8,15 +8,16 @@ import {Config, readConfig} from "./config" import {SessionView, Sessions} from "./control" import {LockedError, acquireLock, createDataFolder, dataRoot, readSessionFile, releaseLock, serverToken, sessionFolder, sessionNumbers, socketPath, writeSessionFile} from "./files" import {Hub, Perform} from "./hub" -import {errorText, log, logError} from "./log" +import {ExitError, errorText, log, logError} from "./log" import {ToolCall} from "./messages" -import {AttachRequest, CallRequest, OwnerState, SessionReply, SessionRequest, StatusReply, protocolVersion} from "./protocol" +import {AttachRequest, CallRequest, OwnerState, SessionReply, SessionRequest, StatusReply, TitleReply, TitleRequest, protocolVersion} from "./protocol" import {Handler, SocketError, listen, sendJson, startLines} from "./socket" import {ToolName} from "./tools" import {version} from "./version" const idleClose = 10 * 60_000 const idleStop = 10 * 60_000 +const maxTitles = 100 const firstFreeFd = 3 export interface HubLike { @@ -64,6 +65,7 @@ export class Daemon { private readonly entries = new Map() private readonly closing = new Map>() private readonly streams = new Set() + private readonly titles = new Map() private nextNumber: number private stopTimer?: NodeJS.Timeout private stopping?: Promise @@ -101,6 +103,7 @@ export class Daemon { private routes(): {[route: string]: Handler} { return { "POST /session": async (body, res) => sendJson(res, 200, await this.onSession(body as SessionRequest)), + "POST /title": async (body, res) => sendJson(res, 200, await this.onTitle(body as TitleRequest)), "POST /attach": (body, res) => this.onAttach(body as AttachRequest, res), "POST /call": (body, res) => this.onCall(body as CallRequest, res), "GET /status": async (_body, res) => sendJson(res, 200, this.status()), @@ -161,16 +164,49 @@ export class Daemon { const agent = await entry.opening if (entry.attached === 0) this.scheduleIdle(request.id, entry) await this.hub.configure(request.config) - const folder = sessionFolder(this.deps.root, request.id) - const stored = readSessionFile(folder)! const name = request.name?.trim() - if (name && name !== stored.name) { - await agent.rename(name) - writeSessionFile(folder, {...stored, name}) - } + if (name) await this.rename(this.folder(request.id), agent, name) return {number: agent.number, displayName: agent.displayName, version: this.deps.version, protocol: protocolVersion, owner: this.hub.owner()} } + private async onTitle({id, name, config}: TitleRequest): Promise { + this.refuseWhenStopping() + const folder = this.folder(id) + await this.hub.configure(config) + const title = name?.trim() + if (title) await this.applyTitle(id, folder, title) + return {mainGroup: this.hub.mainGroup, owner: this.hub.owner(), version: this.deps.version, protocol: protocolVersion} + } + + private async applyTitle(id: string, folder: string, title: string): Promise { + const entry = this.entries.get(id) + if (entry !== undefined) return this.rename(folder, await entry.opening, title) + const stored = readSessionFile(folder) + if (stored === undefined) this.rememberTitle(id, title) + else if (title !== stored.name) writeSessionFile(folder, {...stored, name: title}) + } + + private async rename(folder: string, agent: AgentLike, name: string): Promise { + const stored = readSessionFile(folder)! + if (name === stored.name) return + await agent.rename(name) + writeSessionFile(folder, {...stored, name}) + } + + private rememberTitle(id: string, name: string): void { + const key = id.toLowerCase() + this.titles.delete(key) + this.titles.set(key, name) + if (this.titles.size > maxTitles) this.titles.delete(this.titles.keys().next().value!) + } + + private takeTitle(id: string): string | undefined { + const key = id.toLowerCase() + const title = this.titles.get(key) + this.titles.delete(key) + return title + } + private async onAttach({id}: AttachRequest, res: ServerResponse): Promise { const entry = this.entry(id) await entry.opening @@ -215,11 +251,12 @@ export class Daemon { // the stop timer runs only while no entries exist, so a new entry cancels it private entry(id: string, request?: SessionRequest): Entry { - if (this.stopping !== undefined) throw new SocketError("The SimpleX daemon is stopping.", 503) + this.refuseWhenStopping() + const folder = this.folder(id) const existing = this.entries.get(id) if (existing !== undefined) return existing clearTimeout(this.stopTimer) - const entry: Entry = {attached: 0, calls: 0, opening: this.openAgent(id, request)} + const entry: Entry = {attached: 0, calls: 0, opening: this.openAgent(id, folder, request)} this.entries.set(id, entry) this.scheduleIdle(id, entry) entry.opening.then( @@ -235,14 +272,26 @@ export class Daemon { return entry } - private async openAgent(id: string, request: SessionRequest | undefined): Promise { + private refuseWhenStopping(): void { + if (this.stopping !== undefined) throw new SocketError("The SimpleX daemon is stopping.", 503) + } + + private folder(id: string): string { + try { + return sessionFolder(this.deps.root, id) + } catch (error) { + throw error instanceof ExitError ? new SocketError(error.message, 400) : error + } + } + + private async openAgent(id: string, folder: string, request: SessionRequest | undefined): Promise { await this.closing.get(id) - const folder = sessionFolder(this.deps.root, id) let stored = readSessionFile(folder) if (stored === undefined) { if (request === undefined) throw new SocketError("Unknown session; restart the Claude Code session.", 409) + const title = this.takeTitle(id) createDataFolder(folder) - stored = {number: this.nextNumber++, name: sessionName(request.name, request.project), project: request.project, created: new Date().toISOString()} + stored = {number: this.nextNumber++, name: sessionName(request.name?.trim() || title, request.project), project: request.project, created: new Date().toISOString()} writeSessionFile(folder, stored) } return this.deps.openSession({folder, number: stored.number, name: stored.name, hub: this.hub}) diff --git a/packages/simplex-chat-mcp/src/hook.ts b/packages/simplex-chat-mcp/src/hook.ts index b13d951afe..ba76121e45 100644 --- a/packages/simplex-chat-mcp/src/hook.ts +++ b/packages/simplex-chat-mcp/src/hook.ts @@ -2,7 +2,7 @@ import {DaemonClient} from "./client" import {readConfig} from "./config" import {dataRoot} from "./files" import {errorText} from "./log" -import {SessionReply} from "./protocol" +import {TitleReply} from "./protocol" import {qrCodeText} from "./qr" import {isNotRunning} from "./socket" import {version} from "./version" @@ -34,10 +34,9 @@ export async function sessionStart(stdin: string, env: NodeJS.ProcessEnv = proce const config = readConfig(env) const client = deps?.client ?? DaemonClient.forRoot(dataRoot(env)) await client.ensureDaemon(version) - const reply = await client.request("POST", "/session", {id: input.session_id, name: input.session_title, project: env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd(), config}) - const {link, owners} = reply.owner + const {mainGroup, owner} = await client.request("POST", "/title", {id: input.session_id, name: input.session_title, project: env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd(), config}) + const {link, owners} = owner if (owners.length > 0 || link === undefined) return {} - const {mainGroup} = config return { systemMessage: `SimpleX MCP: scan this QR code with the SimpleX app and tap Join to supervise your agents in "${mainGroup}":\n${qrCodeText(link, "plain")}\n${link}`, hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: `The SimpleX Main group "${mainGroup}" has no owner yet. Tell your user to open this link in the SimpleX app and join: ${link}`}, diff --git a/packages/simplex-chat-mcp/src/protocol.ts b/packages/simplex-chat-mcp/src/protocol.ts index 64b1910564..577f9c8552 100644 --- a/packages/simplex-chat-mcp/src/protocol.ts +++ b/packages/simplex-chat-mcp/src/protocol.ts @@ -21,6 +21,13 @@ export interface SessionRequest { config: Config } +export type TitleRequest = SessionRequest + +export interface TitleReply extends DaemonInfo { + mainGroup: string + owner: OwnerState +} + export interface SessionReply extends DaemonInfo { number: number displayName: string diff --git a/packages/simplex-chat-mcp/test/unit/daemon.test.ts b/packages/simplex-chat-mcp/test/unit/daemon.test.ts index 01241c2132..80036a575a 100644 --- a/packages/simplex-chat-mcp/test/unit/daemon.test.ts +++ b/packages/simplex-chat-mcp/test/unit/daemon.test.ts @@ -127,6 +127,9 @@ afterEach(async () => { const post = (route: string, body?: unknown) => requestJson(socketPath(root), token, "POST", route, body) const register = (id: string, name?: string) => post("/session", {id, name, project: "/p/app", config: defaultConfig}) +const title = (id: string, name?: string, config = defaultConfig) => post("/title", {id, name, project: "/p/app", config}) +const titleReply = {mainGroup: "SimpleX MCP", owner: {owners: [], link: "https://simplex.chat/g#link"}, version: "0.2.0", protocol: protocolVersion} +const sessionsFolder = () => join(root, "_sessions") const lockFile = () => join(root, "lock") function attach(id: string, signal: AbortSignal) { @@ -385,12 +388,12 @@ describe("daemon", () => { expect(agents[0].closed).toBe(true) }) - it("refuses a registration that arrives while the daemon stops", async () => { + it.each(["/session", "/title"])("refuses %s when it arrives while the daemon stops", async route => { const {d, agents, gate, hubStopping, stoppedCode} = deps(root) const finishHubStop = gate("hubStop") daemon = await Daemon.start(d, readDefaultConfig) const requested = once(daemon["server"], "request") - const req = request({socketPath: socketPath(root), method: "POST", path: "/session", headers: {authorization: `Bearer ${token}`}}) + const req = request({socketPath: socketPath(root), method: "POST", path: route, headers: {authorization: `Bearer ${token}`}}) const response = new Promise<{status: number | undefined; text: string}>((resolve, reject) => { req.on("response", res => { let text = "" @@ -409,9 +412,101 @@ describe("daemon", () => { finishHubStop() await stopping expect(agents).toHaveLength(0) + expect(existsSync(sessionsFolder())).toBe(false) expect(await stoppedCode).toBe(0) }) + it("renames an open session on a new title and stores the name", async () => { + const {d, agents, configs} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + await register(id1) + expect(await title(id1, " auth ", {...defaultConfig, mainGroup: "Agents"})).toEqual(titleReply) + expect(configs.at(-1)).toEqual({...defaultConfig, mainGroup: "Agents"}) + expect(agents).toMatchObject([{number: 1, displayName: "SimpleX MCP - auth"}]) + expect(readSessionFile(sessionFolder(root, id1))).toMatchObject({number: 1, name: "auth"}) + await title(id1) + expect(agents).toMatchObject([{displayName: "SimpleX MCP - auth"}]) + expect(readSessionFile(sessionFolder(root, id1))).toMatchObject({name: "auth"}) + }) + + it("renames a session that is still opening once it opens", async () => { + storeSession(id1, 1, "app") + const {d, agents, opening, gate} = deps(root) + const finishOpen = gate("open") + daemon = await Daemon.start(d, readDefaultConfig) + const called = streamLines(socketPath(root), token, "/call", {id: id1, tool: "list", input: {}}, () => undefined) + await opening + const titled = title(id1, "auth") + finishOpen() + expect(await titled).toEqual(titleReply) + await called + expect(agents).toMatchObject([{number: 1, displayName: "SimpleX MCP - auth"}]) + expect(readSessionFile(sessionFolder(root, id1))).toMatchObject({number: 1, name: "auth"}) + }) + + it("renames a stored session in its file without opening it", async () => { + storeSession(id1, 1, "app") + const {d, agents, configs} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + expect(await title(id1, "auth", {...defaultConfig, approve: "host"})).toEqual(titleReply) + expect(configs.at(-1)).toEqual({...defaultConfig, approve: "host"}) + expect(agents).toHaveLength(0) + expect(readSessionFile(sessionFolder(root, id1))).toEqual({number: 1, name: "auth", project: "/p/app", created: "2026-10-24T00:00:00.000Z"}) + expect(await requestJson(socketPath(root), token, "GET", "/status")).toMatchObject({sessions: []}) + expect(await register(id1)).toMatchObject({number: 1, displayName: "SimpleX MCP - auth"}) + }) + + it("remembers the title of an unknown session without a folder or a number, and uses it once", async () => { + const {d, agents} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + expect(await title(id1, "auth")).toEqual(titleReply) + expect(agents).toHaveLength(0) + expect(existsSync(sessionsFolder())).toBe(false) + expect(await requestJson(socketPath(root), token, "GET", "/status")).toMatchObject({sessions: []}) + expect(await register(id2)).toMatchObject({number: 1, displayName: "SimpleX MCP - app"}) + expect(await register(id1)).toMatchObject({number: 2, displayName: "SimpleX MCP - auth"}) + expect(readSessionFile(sessionFolder(root, id1))).toMatchObject({number: 2, name: "auth", project: "/p/app"}) + expect(daemon["titles"].size).toBe(0) + }) + + it("prefers the registration's own name to a remembered title", async () => { + const {d} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + await title(id1, "auth") + expect(await register(id1, "docs")).toMatchObject({number: 1, displayName: "SimpleX MCP - docs"}) + expect(daemon["titles"].size).toBe(0) + }) + + it("remembers at most 100 titles, dropping the oldest", async () => { + const {d} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + const ids = Array.from({length: 101}, (_, n) => `${n.toString(16).padStart(8, "0")}-0000-4000-8000-000000000000`) + for (const [n, id] of ids.entries()) await title(id, `title ${n}`) + expect(daemon["titles"].size).toBe(100) + expect(await register(ids[0])).toMatchObject({displayName: "SimpleX MCP - app"}) + expect(await register(ids[1])).toMatchObject({displayName: "SimpleX MCP - title 1"}) + expect(await register(ids[100])).toMatchObject({displayName: "SimpleX MCP - title 100"}) + }) + + it("does not delay the idle stop with a title", async () => { + jest.useFakeTimers(fakeTimers) + const {d, stoppedCode} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + await jest.advanceTimersByTimeAsync(5 * minutes) + await title(id1, "auth") + await jest.advanceTimersByTimeAsync(5 * minutes) + expect(await stoppedCode).toBe(0) + daemon = undefined + }) + + it.each(["/session", "/title"])("answers 400 to %s with an ID that is not a session ID", async route => { + const {d, agents} = deps(root) + daemon = await Daemon.start(d, readDefaultConfig) + await expect(post(route, {id: "../x", project: "/p/app", config: defaultConfig})).rejects.toMatchObject({message: "Not a Claude Code session ID: ../x", status: 400}) + expect(agents).toHaveLength(0) + expect(existsSync(sessionsFolder())).toBe(false) + }) + it("stops when the /stop client leaves before the reply", async () => { const {d, hubStopping, gate, stoppedCode} = deps(root) const finishHubStop = gate("hubStop") diff --git a/packages/simplex-chat-mcp/test/unit/hook.test.ts b/packages/simplex-chat-mcp/test/unit/hook.test.ts index 473089e746..d5f0e33570 100644 --- a/packages/simplex-chat-mcp/test/unit/hook.test.ts +++ b/packages/simplex-chat-mcp/test/unit/hook.test.ts @@ -5,35 +5,37 @@ import {qrCodeText} from "../../src/qr" const status = {version: "0.2.0", protocol: 1, mainGroup: "SimpleX MCP", sessions: []} const input = JSON.stringify({session_id: "11111111-1111-4111-8111-111111111111", session_title: "auth", cwd: "/p/app", source: "startup"}) -function fakeClient(owner: {owners: string[]; link?: string}, ensureError?: Error) { +function fakeClient(owner: {owners: string[]; link?: string}, ensureError?: Error, mainGroup = "SimpleX MCP") { const requests: unknown[] = [] const fake = { ensureDaemon: async () => { if (ensureError) throw ensureError return {...status, owner} }, - request: async (_method: string, _route: string, body: unknown) => { - requests.push(body) - return {number: 1, displayName: "SimpleX MCP - auth", version: "0.2.0", protocol: 1, owner} + request: async (method: string, route: string, body: unknown) => { + requests.push({method, route, body}) + return {mainGroup, owner, version: "0.2.0", protocol: 1} }, } return {requests, client: fake as unknown as HookDeps["client"]} } describe("SessionStart hook", () => { - it("registers the session and stays quiet when the Main group has an owner", async () => { + it("records the session title and stays quiet when the Main group has an owner", async () => { const {client, requests} = fakeClient({owners: ["Alice"]}) expect(await sessionStart(input, {CLAUDE_PROJECT_DIR: "/p/app"}, {client, nodeVersion: "22.1.0"})).toEqual({}) - expect(requests).toEqual([{id: "11111111-1111-4111-8111-111111111111", name: "auth", project: "/p/app", config: {mainGroup: "SimpleX MCP", approve: "requests", expireHours: 23}}]) + expect(requests).toEqual([ + {method: "POST", route: "/title", body: {id: "11111111-1111-4111-8111-111111111111", name: "auth", project: "/p/app", config: {mainGroup: "SimpleX MCP", approve: "requests", expireHours: 23}}}, + ]) }) - it("shows the link with a QR code while the Main group has no owner", async () => { + it("shows the link of the daemon's Main group with a QR code while it has no owner", async () => { const link = "https://simplex.chat/g#link" - const {client} = fakeClient({owners: [], link}) + const {client} = fakeClient({owners: [], link}, undefined, "Agents") const output = await sessionStart(input, {}, {client, nodeVersion: "22.1.0"}) expect(output).toEqual({ - systemMessage: `SimpleX MCP: scan this QR code with the SimpleX app and tap Join to supervise your agents in "SimpleX MCP":\n${qrCodeText(link, "plain")}\n${link}`, - hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: `The SimpleX Main group "SimpleX MCP" has no owner yet. Tell your user to open this link in the SimpleX app and join: ${link}`}, + systemMessage: `SimpleX MCP: scan this QR code with the SimpleX app and tap Join to supervise your agents in "Agents":\n${qrCodeText(link, "plain")}\n${link}`, + hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: `The SimpleX Main group "Agents" has no owner yet. Tell your user to open this link in the SimpleX app and join: ${link}`}, }) })