Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0995c55a82 | |||
| 41197c667a | |||
| 7da73aa435 | |||
| 3d83cc61b6 | |||
| 334d52bd2b | |||
| 8ff1de73d4 | |||
| d37fdf8e10 | |||
| 7ff88641c0 | |||
| b8ac5622d6 | |||
| 4df3f8594c | |||
| 56e71293d6 | |||
| 2df214cad1 | |||
| f43b84a7ef | |||
| ce4a0cb7f9 | |||
| 52e9fe2a87 | |||
| a22d67c36c | |||
| 0cb98c2a3e | |||
| e6439238d2 | |||
| 967a306ea8 | |||
| 46d09d4d45 | |||
| 13e3bd00f1 | |||
| cd6d98b99f | |||
| 358f177584 | |||
| b60bb57aa4 | |||
| 7003fca873 | |||
| b5d825356e | |||
| 896eb4fc52 | |||
| f8d7438eec | |||
| f7f4e8f95b | |||
| af76910f36 | |||
| f06111f045 | |||
| c6020b7f43 | |||
| 488b798275 | |||
| 0df19967ca | |||
| 6e04015676 | |||
| acaf9477a1 | |||
| 46a89d481a | |||
| c51428414e | |||
| 50405800c6 | |||
| 4aca056bc9 | |||
| 5e725340b4 | |||
| 3fa2064e3e | |||
| 16f9722851 | |||
| 5f0680c6c1 | |||
| 57e0197d75 | |||
| dc4bac3a85 | |||
| f16545ec36 | |||
| d132ed8e64 | |||
| 2a633d604a | |||
| 6a44c0b8ee | |||
| 3f97e34f21 | |||
| 49a8a23d75 | |||
| 1358a32476 | |||
| 9b79160c95 | |||
| 0cbe99677f | |||
| 46b1609528 | |||
| 2b0b08ceda | |||
| 19cc684433 | |||
| fecb157291 | |||
| ac84e7240e | |||
| d5d82bdb00 | |||
| f10edd6718 | |||
| 3f6cd55833 | |||
| a9e8bc4d87 | |||
| 063e0fa76e | |||
| 9e7bd33822 | |||
| 7427865e46 | |||
| ff5f9c76fd | |||
| 641bbfbe2e | |||
| 5516ec4595 | |||
| 762467efd4 | |||
| 3f54bda360 | |||
| 4d1e388a48 | |||
| 10be86587a | |||
| 6a10591413 | |||
| 321c88e05e | |||
| 23562dfa61 | |||
| cb6ebf1d69 | |||
| a006985faf | |||
| 3fce9ec082 | |||
| 03026c70cc |
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"enabledMcpjsonServers": ["storkit"],
|
"enabledMcpjsonServers": [
|
||||||
|
"storkit"
|
||||||
|
],
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(./server/target/debug/storkit:*)",
|
"Bash(./server/target/debug/storkit:*)",
|
||||||
@@ -67,7 +69,8 @@
|
|||||||
"Bash(tail *)",
|
"Bash(tail *)",
|
||||||
"Bash(wc *)",
|
"Bash(wc *)",
|
||||||
"Bash(npx vite:*)",
|
"Bash(npx vite:*)",
|
||||||
"Bash(npm run dev:*)"
|
"Bash(npm run dev:*)",
|
||||||
|
"Bash(stat *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: "Rename project from \"storkit\" to \"huskies\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 455: Rename project from "storkit" to "huskies"
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project maintainer, I want to rename the project from "storkit" to "huskies" so that the product has its new identity throughout the codebase, tooling, and documentation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Rust crate name in server/Cargo.toml changed from 'storkit' to 'huskies'
|
||||||
|
- [ ] Binary name changed to 'huskies' (Dockerfile CMD, release script binary names)
|
||||||
|
- [ ] Environment variables renamed: STORKIT_PORT → HUSKIES_PORT, STORKIT_HOST → HUSKIES_HOST
|
||||||
|
- [ ] Docker service name, container_name, image name, and volume names updated in docker-compose.yml
|
||||||
|
- [ ] Docker user/group renamed from 'storkit' to 'huskies' in Dockerfile (groupadd, useradd, home dir /home/huskies/.claude)
|
||||||
|
- [ ] MCP server registration renamed from 'storkit' to 'huskies' in scaffold-generated .mcp.json and in server/src/http/mcp/mod.rs serverInfo name
|
||||||
|
- [ ] All 35+ MCP tool permission patterns updated from mcp__storkit__* to mcp__huskies__* across code and permission configs
|
||||||
|
- [ ] The .storkit/ project directory marker renamed to .huskies/ throughout all Rust source (paths.rs, config.rs, scaffold.rs, watcher.rs, prompts.rs, and all agent/pipeline code)
|
||||||
|
- [ ] Release script updated: Gitea repo path dave/storkit → dave/huskies, changelog regex updated to match ^(huskies|storkit|story-kit): for backwards-compatible history parsing, binary artifact names updated
|
||||||
|
- [ ] Git commit prefix convention updated from 'storkit:' to 'huskies:' in storkit README and agent prompts
|
||||||
|
- [ ] Website updated: page title, headings, and contact email (hello@storkit.dev) if domain changes
|
||||||
|
- [ ] README.md updated: all CLI examples use 'huskies' binary name, all .storkit/ references become .huskies/
|
||||||
|
- [ ] A migration path exists for existing installs: either storkit auto-detects and migrates .storkit/ → .huskies/, or a migration script (script/migrate) is provided
|
||||||
|
- [ ] All Claude Code .mcp.json files in existing worktrees are regenerated via scaffold or migration
|
||||||
|
- [ ] Gitea repository renamed from dave/storkit to dave/huskies (external action required, noted in story)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "strip_bot_mention fails on Element markdown mention pill format"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 461: strip_bot_mention fails on Element markdown mention pill format
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When Element sends a message with a mention pill, the plain text body uses Markdown link format: `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function in chat/util.rs uses `strip_prefix_ci` which expects the message to start with `@timmy` or the display name. Since the message starts with `[`, all prefix checks fail, the mention is not stripped, and the entire Markdown link becomes the "command name". Deterministic commands like `status`, `help`, etc. are never matched — they fall through to the LLM instead. The `mentions_bot` function works correctly because it uses `contains()` rather than prefix matching, so the bot IS triggered, but the command text extraction is broken.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. In Element, mention the bot using a mention pill: @botname status. 2. Element sends plain body as `[@bot:server](https://matrix.to/#/@bot:server) status`. 3. Observe that the bot routes to LLM instead of the deterministic status command handler.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
strip_bot_mention returns the original text unchanged. The command name is parsed as the entire Markdown link. No deterministic command matches. Message falls through to LLM.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
strip_bot_mention strips the Markdown mention pill `[...](https://matrix.to/...)` and returns `status`. The deterministic command handler matches and handles it.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] strip_bot_mention in chat/util.rs handles the Markdown mention pill format [display](https://matrix.to/#/@user:server)
|
||||||
|
- [ ] Deterministic commands like 'status', 'help', 'overview' work when sent via Element mention pills
|
||||||
|
- [ ] Existing plain-text mention formats (@bot:server command, @bot command, BotName command) continue to work
|
||||||
|
- [ ] Tests added for Markdown mention pill format in util.rs
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: "strip_bot_mention fails on Element Markdown mention pill format"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 460: strip_bot_mention fails on Element Markdown mention pill format
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When Element sends a mention pill, the plain text `body` field contains a Markdown-style link like `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status`. The `strip_bot_mention` function uses prefix matching, so it tries to match `@timmy:crashlabs.io`, `@timmy`, and `Timmy` against text starting with `[` — none match. The entire message falls through to the LLM as a non-command.
|
||||||
|
|
||||||
|
`mentions_bot` works because it uses `body.contains(full_id)` which finds the MXID embedded inside the Markdown link. But `strip_bot_mention` fails because the text starts with `[`, not `@` or the display name.
|
||||||
|
|
||||||
|
This causes all deterministic bot commands (status, help, ambient, etc.) to be routed to the LLM instead of being handled by the bot when the user uses Element's mention pill (@-autocomplete).
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. In Element, type `@timmy` and use the autocomplete pill to mention the bot
|
||||||
|
2. Append a command like `status`
|
||||||
|
3. Send the message
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
The command falls through to the LLM. The bot logs show no "Handled bot command" entry. The plain body is `[@timmy:crashlabs.io](https://matrix.to/#/@timmy:crashlabs.io) status` which `strip_bot_mention` cannot parse.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The bot should strip the Markdown mention link wrapper, extract the MXID or display name, and match the command deterministically. `@timmy status` via mention pill should produce the same pipeline status output as typing `@timmy status` manually.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] strip_bot_mention handles Markdown link format `[display](https://matrix.to/#/@user:server) command` and extracts the command text
|
||||||
|
- [ ] Deterministic commands (status, help, ambient, etc.) work when invoked via Element mention pill autocomplete
|
||||||
|
- [ ] Unit tests cover the Markdown mention pill body format
|
||||||
|
- [ ] Existing strip_bot_mention tests still pass (plain @mention and display name formats)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "OAuth login button in web UI"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 446: OAuth login button in web UI
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user of the storkit web UI, I want a login button that triggers the Anthropic OAuth flow, so that I can authenticate without manually navigating to /oauth/authorize.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Web UI shows a login/authenticate button when no OAuth token is active
|
||||||
|
- [ ] Clicking the button navigates to /oauth/authorize which starts the Anthropic OAuth flow
|
||||||
|
- [ ] After successful OAuth callback, the UI updates to show the authenticated state
|
||||||
|
- [ ] If already authenticated, the button is hidden or shows the current auth status
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Element tab-completion display name breaks bot command matching"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 447: Element tab-completion display name breaks bot command matching
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When a user tab-completes a bot mention in Element, the Matrix client inserts the display name (e.g. `timmy ⚡️`) rather than the user ID (`@timmy`). If the display name contains emoji or special characters, the `strip_bot_mention` function in chat::util may fail to match it against the bot name, causing commands like `ambient on` to not be recognized.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Set bot display_name to include emoji (e.g. `timmy ⚡️`) in bot.toml\n2. In Element, tab-complete the bot name to get `timmy ⚡️`\n3. Send `timmy ⚡️ ambient on`\n4. The bot does not respond — command not matched
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Bot ignores the command. The display name with emoji doesn't match during strip_bot_mention, so the command text is not correctly extracted.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Bot should recognize commands regardless of whether the mention was tab-completed with the display name (including emoji) or typed manually as @localpart.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] strip_bot_mention handles display names containing emoji and special characters
|
||||||
|
- [ ] strip_bot_mention handles Element's tab-completion format (display name followed by colon or comma)
|
||||||
|
- [ ] Commands work whether the user types @timmy, timmy, or tab-completes timmy ⚡️
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Send OAuth login link via chat when credentials are missing"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 448: Send OAuth login link via chat when credentials are missing
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a storkit user on Matrix or WhatsApp, I want the bot to send me a clickable OAuth authorize link when credentials are missing or expired, so that I can authenticate without terminal access or manually constructing the URL.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When storkit detects missing or expired credentials during a chat interaction, it sends the user a clickable /oauth/authorize link
|
||||||
|
- [ ] Works on Matrix and WhatsApp transports
|
||||||
|
- [ ] After successful OAuth callback, the user can immediately resume chatting without restarting storkit
|
||||||
|
- [ ] If credentials are already valid, no login link is sent
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "OAuth callback URL ignores --port CLI flag"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 449: OAuth callback URL ignores --port CLI flag
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
OAuthState is initialized with `resolve_port()` (reads STORKIT_PORT env var, defaults to 3001) instead of the actual port the server is listening on. When the server is started with `--port 4000`, the OAuth callback URL is still generated as `http://localhost:3001/callback`, so the Anthropic redirect lands on the wrong server and the state parameter lookup fails with "Unknown or expired state parameter".
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Start storkit with `--port 4000` (without setting STORKIT_PORT env var). Click the OAuth login button in the web UI. Authenticate with Anthropic. The callback redirect goes to localhost:3001 instead of localhost:4000.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Callback hits port 3001 (or wrong port). If a different storkit is running there, it returns "Invalid State". If nothing is running there, the page fails to load.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Callback URL should use the actual server port (from --port CLI flag), so the redirect returns to the correct server instance.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] build_routes receives the actual listening port and passes it to OAuthState::new
|
||||||
|
- [ ] OAuth callback URL matches the port the server is actually listening on
|
||||||
|
- [ ] Works with --port flag, STORKIT_PORT env var, and default port
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Web UI silently swallows chat errors including OAuth login link"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 450: Web UI silently swallows chat errors including OAuth login link
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When the WebSocket chat returns an error (e.g. OAuth authentication failed with a login URL), the `onError` handler in `Chat.tsx` only logs to `console.error` and resets loading state. The error message is never displayed to the user. This means the OAuth login link from story #448 works on Matrix/WhatsApp but is invisible in the web UI.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Use the web UI with missing or expired OAuth credentials. Send any chat message. The server detects auth failure, attempts token refresh, fails, and returns an error containing a login URL over WebSocket.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Nothing visible happens. The error is logged to browser console only. The user sees no feedback.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The error message (including the clickable OAuth login link) should be displayed in the chat as an assistant message so the user can act on it.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] WebSocket error messages are displayed in the chat UI as assistant messages
|
||||||
|
- [ ] OAuth login URL in the error is rendered as a clickable link
|
||||||
|
- [ ] Consistent with how Matrix and WhatsApp transports display the same error
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Chat.test.tsx /help test expects removed overlay behavior"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 451: Chat.test.tsx /help test expects removed overlay behavior
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The test `AC: /help shows help overlay` in `Chat.test.tsx:1645` expects `/help` to show the `help-overlay` testid element. However, the `/help` intercept was removed from Chat.tsx and `help` was added to `knownCommands`, so `/help` now goes through `api.botCommand()` like other commands. The test needs to be updated to expect a `botCommand("help", ...)` call instead of the overlay. This is blocking gates on stories 449 and 450.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Run `cd frontend && npm test` — the test `AC: /help shows help overlay` fails.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Test fails: `findByTestId("help-overlay")` times out because the overlay is never rendered. `/help` is dispatched to the backend via `botCommand` instead.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Test should pass by expecting `/help` to call `api.botCommand("help", ...)` and display the response in chat, consistent with the current code behavior.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The /help test in Chat.test.tsx is updated to expect botCommand dispatch
|
||||||
|
- [ ] All frontend tests pass
|
||||||
|
- [ ] HelpOverlay component and showHelp state can be removed from Chat.tsx if no longer used
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: "Zombie process accumulation from unrereaped child processes"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 452: Zombie process accumulation from unrereaped child processes
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Storkit accumulates zombie processes over time from unrereaped child and grandchild processes. Observed 101 zombies in Docker container, 27 on macOS host. Breakdown: 51 esbuild, 36 echo, 5 claude, 5 sh, 2 bash, 1 cargo.
|
||||||
|
|
||||||
|
Root cause: storkit does not reap orphaned grandchild processes. The zombies are mostly grandchildren (`esbuild`, `echo`, `sh`, `cargo`) spawned by `npm run build`, `cargo test`, etc. during worktree setup and gate checks. This happens both natively (observed 27 zombies on macOS host) and in Docker containers. When the intermediate parent exits, these grandchildren get reparented to storkit (or PID 1 in Docker) and become zombies because nobody calls `waitpid` for them.
|
||||||
|
|
||||||
|
**Already fixed:**
|
||||||
|
- `docker-compose.yml` now has `init: true` which uses tini as PID 1 in Docker — this handles zombie reaping inside containers
|
||||||
|
- `llm/providers/claude_code.rs` now has `child.wait()` after `child.kill()` in all code paths, and the reader thread is joined before returning
|
||||||
|
- `agents/pty.rs` reader thread is now joined before returning
|
||||||
|
|
||||||
|
**Remaining:** Storkit running natively (e.g. on macOS) still accumulates zombie grandchildren because there is no tini. The fix is to add a background reaper thread that periodically calls `waitpid(-1, WNOHANG)` in a loop to clean up any orphaned children. This should be spawned early in `main()` on Unix platforms. Example:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::thread::spawn(|| {
|
||||||
|
loop {
|
||||||
|
unsafe { while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {} }
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Run several agent sessions. Check with `ps -eo stat,comm | grep Z | awk '{print $2}' | sort | uniq -c | sort -rn`.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Zombie processes accumulate continuously. Never reaped.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
No zombie accumulation during normal operation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `child.wait()` is called after `child.kill()` in all code paths in `claude_code.rs`
|
||||||
|
- [x] Reader threads are joined in both `pty.rs` and `claude_code.rs`
|
||||||
|
- [x] `init: true` added to docker-compose.yml for Docker deployments
|
||||||
|
- [ ] Background reaper thread added for native (non-Docker) deployments
|
||||||
|
- [ ] Verified with `ps aux | grep '<defunct>'` after running multiple agent sessions natively on macOS
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: "Agent PTY crashes with fatal runtime error on restart after gate failure"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 453: Agent PTY crashes with fatal runtime error on restart after gate failure
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When an agent completes coding and the acceptance gates fail (e.g. a test failure), the pipeline restarts the agent on the same worktree. The restarted Claude Code PTY process crashes immediately with `fatal runtime error: assertion failed: output.write(&bytes).is_ok(), aborting`. The process exits in the same second it spawns (Session: None), burns through all 3 retries, and blocks the story.
|
||||||
|
|
||||||
|
Key observations:
|
||||||
|
- The crash is **deterministic, not intermittent**: the first PTY spawn in a worktree always works; the second spawn (restart) always crashes
|
||||||
|
- Running `claude -p "hello"` manually in the same worktree works fine (no crash) — the issue is specific to spawning via portable-pty
|
||||||
|
- The worktree is clean (all changes committed) — the agent has nothing to do but fix the gate failure
|
||||||
|
- The crash is inside the Claude Code binary, not storkit code
|
||||||
|
- Observed on every story that needed a restart: 329, 400, 420, 438, 446, 449, 450
|
||||||
|
- Stories that passed gates on the first run were never affected — they never triggered a second spawn
|
||||||
|
|
||||||
|
Likely cause: the reader thread spawned by `std::thread::spawn` in `pty.rs` (line 248-255) is never joined. After `run_agent_pty_streaming` returns, the pipeline immediately calls `start_agent` for the retry, but the old reader thread may still be running and holding a cloned PTY reader fd. The new PTY allocation could collide with the still-open fd from the previous session.
|
||||||
|
|
||||||
|
The root cause is unknown. It is NOT caused by zombie process accumulation (that is a separate issue in #452).
|
||||||
|
|
||||||
|
**Timeline:** The crash first appeared on 2026-03-21. Agent logs go back to 2026-02-23 with no instances before that date. Stories that hit it: 329 (Mar 21), 400 (Mar 26), 420 (Mar 28), 438 (Mar 28), 446 (Mar 30), 449 (Mar 31), 450 (Mar 31).
|
||||||
|
|
||||||
|
**Suspect commits around 2026-03-21:**
|
||||||
|
- `4344081b` — storkit: merge 343_refactor_abstract_agent_runtime_to_support_non_claude_code_backends (refactored agent runtime layer)
|
||||||
|
- `c4e45b28` — The great storkit name conversion
|
||||||
|
- Story 359 — Docker security hardening (`cap_drop: ALL`, added back only `SETUID`/`SETGID`) — could affect PTY allocation
|
||||||
|
- Story 329 — Docker/OrbStack evaluation spike (first crash was on this story's mergemaster)
|
||||||
|
|
||||||
|
**Ruled out:** Docker capability restrictions (cap_drop: ALL) — tested by temporarily removing all cap_drop/security_opt; crash still occurs.
|
||||||
|
|
||||||
|
**Evidence of stale PTY fd:** After all agents stopped, storkit (PID 7) was still holding an open fd to `/dev/pts/ptmx` (fd 46). This is a leaked PTY master fd from a previous agent session. The reader thread spawned by `std::thread::spawn` in `pty.rs` is never joined, so the cloned reader fd stays open in the storkit process after the agent exits.
|
||||||
|
|
||||||
|
Remaining areas to investigate: the unjoined reader thread leaking PTY fds, and whether the leaked fd from the first session interferes with the second PTY allocation.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Have a story in current stage with committed code in its worktree. 2. Introduce a test failure that causes gates to fail. 3. The pipeline restarts the agent on the same worktree. 4. The Claude Code process crashes immediately on spawn.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
`fatal runtime error: assertion failed: output.write(&bytes).is_ok(), aborting` — process exits instantly (same second as spawn), Session: None. Burns through retries and blocks the story.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The restarted agent should start successfully, receive the gate failure context, and be able to fix the issue.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Agent restart after gate failure successfully spawns a Claude Code PTY session
|
||||||
|
- [ ] No fatal runtime error on PTY restart in a worktree with prior committed work
|
||||||
|
- [ ] If Claude Code fails to start, the error is handled gracefully without burning retries
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Deduplicate work item display in web UI story panel"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 454: Deduplicate work item display in web UI story panel
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want the work item detail panel to display cleanly without redundant information, so that I can read story details without noise.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The story title is not shown twice (remove the duplicate heading)
|
||||||
|
- [ ] The work item type label is not shown twice
|
||||||
|
- [ ] The word 'name' is not shown as a prefix before the story title
|
||||||
|
- [ ] The story ID/title line (e.g. 'Story 3: ...') is left-justified with no extra indentation
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot ignores in-room verification requests from Element"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 456: Matrix bot ignores in-room verification requests from Element
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The Matrix bot (Sally) only registers a handler for to-device verification events (`ToDeviceKeyVerificationRequestEvent`). Modern Element clients use in-room verification (`m.key.verification.request` as a room message event) by default. When a user initiates "Start Verification" from Element, the request is sent as a room event and the bot never sees it — nothing appears in the bot logs and the verification flow hangs indefinitely. As a result, Sally's device remains unverified (Big Red Dot), and if Element has "never send to unverified sessions" enabled, it will not share Megolm room keys with Sally's device, making her deaf to all encrypted room messages.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Run the storkit Matrix bot (Sally) in a room with E2EE enabled. 2. In Element, open the room member list, click Sally's device, and press "Start Verification". 3. Watch the bot logs: grep for "verif\|Incoming".
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Nothing appears in the bot logs. The verification flow hangs in Element and eventually times out. Sally's device remains unverified. If Element is set to encrypt only to verified sessions, Sally cannot decrypt any messages in the room.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The bot receives the in-room verification request, accepts it, drives the SAS emoji flow to completion, and logs "Verification with @user completed successfully!". Sally's device shows as verified in Element.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Bot registers an in-room verification event handler for m.key.verification.request room events (in addition to the existing to-device handler)
|
||||||
|
- [ ] When Element initiates 'Start Verification' from the device list, the bot logs 'Incoming verification request from ...'
|
||||||
|
- [ ] The SAS emoji flow completes: bot logs the emoji string, confirms, and logs 'Verification ... completed successfully!'
|
||||||
|
- [ ] Sally's device shows as verified (no Big Red Dot) in Element after the flow completes
|
||||||
|
- [ ] Existing to-device verification handler is preserved for clients that use the older flow
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: "store.json created at project root instead of inside .storkit/"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 457: store.json created at project root instead of inside .storkit/
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
In main.rs, JsonFileStore is initialised with a hardcoded relative path `PathBuf::from("store.json")`, which creates the file in whatever directory the process was started from (typically the project root). It should live inside `.storkit/` alongside other runtime state files. The scaffold .gitignore also lists `store.json` as a root-level pattern rather than `.storkit/store.json`, and the scaffold comment/entries array in scaffold.rs explicitly lists `store.json` as a root-level file to ignore — both need updating.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Run storkit in any project directory. 2. Observe that store.json is created at the project root rather than inside .storkit/.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
store.json is created at the working directory root, polluting the project root and not being gitignored by the scaffold-generated .gitignore unless the user happens to have a catch-all pattern.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
store.json is created at project_root/.storkit/store.json. The scaffold-generated .gitignore ignores .storkit/store.json. The scaffold comment and entries array in scaffold.rs no longer list store.json as a root-level file.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] main.rs initialises JsonFileStore at project_root.join(".storkit").join("store.json") instead of PathBuf::from("store.json")
|
||||||
|
- [ ] scaffold.rs .gitignore entries updated: store.json root entry removed, .storkit/store.json added
|
||||||
|
- [ ] scaffold.rs comment on line ~333 updated to reflect store.json is no longer at the root
|
||||||
|
- [ ] wizard_tools.rs filter for store.json updated to match the new path if needed
|
||||||
|
- [ ] Existing deployments with a root-level store.json are not broken (storkit migrates or falls back gracefully)
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot ignores messages addressed to other bots in ambient mode"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 458: Matrix bot ignores messages addressed to other bots in ambient mode
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user with multiple bots in the same Matrix room, I want each bot to only respond to messages addressed to it in ambient mode, so that bots don't step on each other's responses.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] In ambient mode, the bot ignores messages that begin with another bot's name or mention another bot's display name (e.g. 'sally: do X' or '@sally do X' is ignored by stu)
|
||||||
|
- [ ] In ambient mode, the bot still responds to messages with no explicit addressee
|
||||||
|
- [ ] In ambient mode, the bot still responds to messages explicitly addressed to itself (e.g. 'stu: do X' or '@stu do X')
|
||||||
|
- [ ] Direct @mention of the bot's Matrix user ID always triggers a response regardless of ambient mode
|
||||||
|
- [ ] The bot's own display_name from bot.toml is used to detect when it is being addressed
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "matrix_history.json and timers.json missing from scaffold .storkit/.gitignore"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 459: matrix_history.json and timers.json missing from scaffold .storkit/.gitignore
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The scaffold's write_story_kit_gitignore function in scaffold.rs does not include matrix_history.json or timers.json in the .storkit/.gitignore entries. Both files are runtime state that should not be committed to git. matrix_device_id and matrix_store/ are already covered, but matrix_history.json (conversation history) and timers.json (timer store) are missing.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Run storkit scaffold on a new project. 2. Start the Matrix bot. 3. Observe that matrix_history.json and timers.json are created inside .storkit/ but are not gitignored.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
matrix_history.json and timers.json appear as untracked files in git status.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Both files are listed in .storkit/.gitignore and do not appear in git status.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] matrix_history.json added to the entries array in write_story_kit_gitignore in scaffold.rs
|
||||||
|
- [ ] timers.json added to the entries array in write_story_kit_gitignore in scaffold.rs
|
||||||
|
- [ ] scaffold test in scaffold_creates_story_kit_gitignore_with_relative_entries asserts both entries are present
|
||||||
Generated
+180
-121
@@ -26,7 +26,7 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -265,16 +265,16 @@ checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blake3"
|
name = "blake3"
|
||||||
version = "1.8.3"
|
version = "1.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
|
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -286,6 +286,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-padding"
|
name = "block-padding"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -391,7 +400,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -427,7 +436,7 @@ version = "0.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"inout",
|
"inout",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -492,6 +501,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const_panic"
|
name = "const_panic"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -551,6 +566,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -596,6 +620,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctr"
|
name = "ctr"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -612,9 +645,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -740,7 +773,7 @@ version = "0.7.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -813,11 +846,22 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.0",
|
||||||
|
"const-oid 0.10.2",
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -862,7 +906,7 @@ dependencies = [
|
|||||||
"ed25519",
|
"ed25519",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1371,7 +1415,7 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1452,10 +1496,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hybrid-array"
|
||||||
version = "1.8.1"
|
version = "0.4.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1468,7 +1521,6 @@ dependencies = [
|
|||||||
"httpdate",
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"want",
|
"want",
|
||||||
@@ -1542,12 +1594,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"potential_utf",
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
@@ -1555,9 +1608,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_locale_core"
|
name = "icu_locale_core"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"litemap",
|
"litemap",
|
||||||
@@ -1568,9 +1621,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer"
|
name = "icu_normalizer"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_normalizer_data",
|
"icu_normalizer_data",
|
||||||
@@ -1582,15 +1635,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer_data"
|
name = "icu_normalizer_data"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties"
|
name = "icu_properties"
|
||||||
version = "2.1.2"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
|
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
@@ -1602,15 +1655,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties_data"
|
name = "icu_properties_data"
|
||||||
version = "2.1.2"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
|
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_provider"
|
name = "icu_provider"
|
||||||
version = "2.1.1"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_locale_core",
|
"icu_locale_core",
|
||||||
@@ -1726,9 +1779,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
@@ -1862,9 +1915,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.92"
|
version = "0.3.94"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1950,9 +2003,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.184"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
@@ -1985,9 +2038,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
@@ -2158,7 +2211,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_html_form",
|
"serde_html_form",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2251,7 +2304,7 @@ dependencies = [
|
|||||||
"ruma",
|
"ruma",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
@@ -2286,7 +2339,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2340,7 +2393,7 @@ dependencies = [
|
|||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -2599,7 +2652,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -2657,7 +2710,7 @@ version = "0.12.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"hmac",
|
"hmac",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2711,12 +2764,6 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-utils"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -2842,7 +2889,7 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"opaque-debug",
|
"opaque-debug",
|
||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
@@ -2870,9 +2917,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
|
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
@@ -3504,7 +3551,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"ruma-common",
|
"ruma-common",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3552,7 +3599,7 @@ version = "8.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3826,9 +3873,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -3876,8 +3923,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3887,8 +3934,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"digest 0.11.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4019,7 +4077,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.8.2"
|
version = "0.8.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4030,6 +4088,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"homedir",
|
"homedir",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"libc",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"matrix-sdk",
|
"matrix-sdk",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
@@ -4046,12 +4105,12 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2 0.11.0",
|
||||||
"strip-ansi-escapes",
|
"strip-ansi-escapes",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml 1.1.0+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wait-timeout",
|
"wait-timeout",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -4272,9 +4331,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
@@ -4297,9 +4356,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.50.0"
|
version = "1.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4314,9 +4373,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.6.1"
|
version = "2.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4398,17 +4457,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.0+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
|
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime 1.1.0+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4422,39 +4481,39 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.0+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.8+spec-1.1.0"
|
version = "0.25.10+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime 1.1.0+spec-1.1.0",
|
"toml_datetime 1.1.1+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.0+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.1.0+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -4610,9 +4669,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typewit"
|
name = "typewit"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71"
|
checksum = "06fee3a8df48c50c55ad646a4e03b00a370da6fe1850ebf467a8d0165dfcafae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typewit_proc_macros",
|
"typewit_proc_macros",
|
||||||
]
|
]
|
||||||
@@ -4681,7 +4740,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4781,7 +4840,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
@@ -4851,9 +4910,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4864,9 +4923,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.65"
|
version = "0.4.67"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -4874,9 +4933,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -4884,9 +4943,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4897,9 +4956,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.115"
|
version = "0.2.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -4984,9 +5043,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.92"
|
version = "0.3.94"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -5465,9 +5524,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -5571,9 +5630,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.2"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x25519-dalek"
|
name = "x25519-dalek"
|
||||||
@@ -5595,9 +5654,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
|
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
@@ -5606,9 +5665,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5638,18 +5697,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
|
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerofrom-derive",
|
"zerofrom-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom-derive"
|
name = "zerofrom-derive"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5679,9 +5738,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
|
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"yoke",
|
"yoke",
|
||||||
@@ -5690,9 +5749,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec"
|
name = "zerovec"
|
||||||
version = "0.11.5"
|
version = "0.11.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
@@ -5701,9 +5760,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec-derive"
|
name = "zerovec-derive"
|
||||||
version = "0.11.2"
|
version = "0.11.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,7 @@ rust-embed = "8"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11.0"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
strip-ansi-escapes = "0.2"
|
strip-ansi-escapes = "0.2"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -40,3 +40,4 @@ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
|
|||||||
"html",
|
"html",
|
||||||
] }
|
] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
libc = "0.2"
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# Use tini as PID 1 to reap zombie child processes.
|
||||||
|
# Without this, grandchild processes (esbuild, cargo, etc.) spawned by
|
||||||
|
# npm/cargo during worktree setup and gate checks become zombies.
|
||||||
|
init: true
|
||||||
|
|
||||||
# Restart policy – restart on crash but not on manual stop
|
# Restart policy – restart on crash but not on manual stop
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.8.2",
|
"version": "0.8.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.8.2",
|
"version": "0.8.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.2",
|
"version": "0.8.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ vi.mock("./api/client", () => {
|
|||||||
setModelPreference: vi.fn(),
|
setModelPreference: vi.fn(),
|
||||||
cancelChat: vi.fn(),
|
cancelChat: vi.fn(),
|
||||||
setAnthropicApiKey: vi.fn(),
|
setAnthropicApiKey: vi.fn(),
|
||||||
|
getOAuthStatus: vi.fn(),
|
||||||
};
|
};
|
||||||
class ChatWebSocket {
|
class ChatWebSocket {
|
||||||
connect() {}
|
connect() {}
|
||||||
@@ -65,6 +66,12 @@ describe("App", () => {
|
|||||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
|
mockedApi.getOAuthStatus.mockResolvedValue({
|
||||||
|
authenticated: false,
|
||||||
|
expired: false,
|
||||||
|
expires_at: 0,
|
||||||
|
has_refresh_token: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderApp() {
|
async function renderApp() {
|
||||||
|
|||||||
+28
-1
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import type { OAuthStatus } from "./api/client";
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||||
@@ -14,6 +15,27 @@ function App() {
|
|||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||||
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
||||||
|
const [oauthStatus, setOauthStatus] = React.useState<OAuthStatus | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
function fetchOAuthStatus() {
|
||||||
|
api
|
||||||
|
.getOAuthStatus()
|
||||||
|
.then((s) => {
|
||||||
|
if (active) setOauthStatus(s);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
fetchOAuthStatus();
|
||||||
|
const intervalId = window.setInterval(fetchOAuthStatus, 5000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -182,10 +204,15 @@ function App() {
|
|||||||
onCloseSuggestions={closeSuggestions}
|
onCloseSuggestions={closeSuggestions}
|
||||||
completionError={completionError}
|
completionError={completionError}
|
||||||
currentPartial={currentPartial}
|
currentPartial={currentPartial}
|
||||||
|
oauthStatus={oauthStatus}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="workspace" style={{ height: "100%" }}>
|
<div className="workspace" style={{ height: "100%" }}>
|
||||||
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
<Chat
|
||||||
|
projectPath={projectPath}
|
||||||
|
onCloseProject={closeProject}
|
||||||
|
oauthStatus={oauthStatus}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,13 @@ export interface CommandOutput {
|
|||||||
exit_code: number;
|
exit_code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
expires_at: number;
|
||||||
|
has_refresh_token: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
declare const __STORKIT_PORT__: string;
|
declare const __STORKIT_PORT__: string;
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
@@ -402,6 +409,10 @@ export const api = {
|
|||||||
deleteStory(storyId: string) {
|
deleteStory(storyId: string) {
|
||||||
return callMcpTool("delete_story", { story_id: storyId });
|
return callMcpTool("delete_story", { story_id: storyId });
|
||||||
},
|
},
|
||||||
|
/** Fetch OAuth status from the server. */
|
||||||
|
getOAuthStatus() {
|
||||||
|
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
||||||
|
},
|
||||||
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||||
botCommand(command: string, args: string, baseUrl?: string) {
|
botCommand(command: string, args: string, baseUrl?: string) {
|
||||||
return requestJson<{ response: string }>(
|
return requestJson<{ response: string }>(
|
||||||
|
|||||||
@@ -1642,7 +1642,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AC: /help shows help overlay", async () => {
|
it("AC: /help calls botCommand and displays response", async () => {
|
||||||
|
mockedApi.botCommand.mockResolvedValue({
|
||||||
|
response: "Available commands: status, help, ...",
|
||||||
|
});
|
||||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
@@ -1658,9 +1661,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByTestId("help-overlay")).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined);
|
||||||
|
});
|
||||||
expect(lastSendChatArgs).toBeNull();
|
expect(lastSendChatArgs).toBeNull();
|
||||||
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AC: botCommand API error shows error message in chat", async () => {
|
it("AC: botCommand API error shows error message in chat", async () => {
|
||||||
@@ -1685,3 +1689,42 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Bug 450: WebSocket error messages displayed in chat", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedWsHandlers = null;
|
||||||
|
setupMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC1: WebSocket error message is shown in chat as an assistant message", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
capturedWsHandlers?.onError("Something went wrong on the server.");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Something went wrong on the server."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC2: OAuth login URL in WebSocket error is rendered as a clickable link", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
capturedWsHandlers?.onError(
|
||||||
|
"OAuth login required. Please visit: https://example.com/oauth/login",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", {
|
||||||
|
name: /https:\/\/example\.com\/oauth\/login/,
|
||||||
|
});
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute("href", "https://example.com/oauth/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AgentConfigInfo } from "../api/agents";
|
|||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import type {
|
import type {
|
||||||
AnthropicModelInfo,
|
AnthropicModelInfo,
|
||||||
|
OAuthStatus,
|
||||||
PipelineState,
|
PipelineState,
|
||||||
WizardStateData,
|
WizardStateData,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
@@ -164,9 +165,14 @@ const getContextWindowSize = (
|
|||||||
interface ChatProps {
|
interface ChatProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCloseProject: () => void;
|
onCloseProject: () => void;
|
||||||
|
oauthStatus?: OAuthStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
export function Chat({
|
||||||
|
projectPath,
|
||||||
|
onCloseProject,
|
||||||
|
oauthStatus = null,
|
||||||
|
}: ChatProps) {
|
||||||
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
|
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [model, setModel] = useState("claude-code-pty");
|
const [model, setModel] = useState("claude-code-pty");
|
||||||
@@ -407,6 +413,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
console.error("WebSocket error:", message);
|
console.error("WebSocket error:", message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setActivityStatus(null);
|
setActivityStatus(null);
|
||||||
|
const markdownMessage = message.replace(
|
||||||
|
/(https?:\/\/[^\s]+)/g,
|
||||||
|
"[$1]($1)",
|
||||||
|
);
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "assistant", content: markdownMessage },
|
||||||
|
]);
|
||||||
if (queuedMessagesRef.current.length > 0) {
|
if (queuedMessagesRef.current.length > 0) {
|
||||||
const batch = queuedMessagesRef.current.map((item) => item.text);
|
const batch = queuedMessagesRef.current.map((item) => item.text);
|
||||||
queuedMessagesRef.current = [];
|
queuedMessagesRef.current = [];
|
||||||
@@ -615,12 +629,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const sendMessage = async (messageText: string) => {
|
const sendMessage = async (messageText: string) => {
|
||||||
if (!messageText.trim()) return;
|
if (!messageText.trim()) return;
|
||||||
|
|
||||||
// /help — show available slash commands overlay
|
|
||||||
if (/^\/help\s*$/i.test(messageText)) {
|
|
||||||
setShowHelp(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /reset — clear session and message history without LLM
|
// /reset — clear session and message history without LLM
|
||||||
if (/^\/reset\s*$/i.test(messageText)) {
|
if (/^\/reset\s*$/i.test(messageText)) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -657,6 +665,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
"overview",
|
"overview",
|
||||||
"rebuild",
|
"rebuild",
|
||||||
"loc",
|
"loc",
|
||||||
|
"help",
|
||||||
|
"ambient",
|
||||||
|
"htop",
|
||||||
|
"rmtree",
|
||||||
|
"timer",
|
||||||
|
"unblock",
|
||||||
|
"unreleased",
|
||||||
|
"setup",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (knownCommands.has(cmd)) {
|
if (knownCommands.has(cmd)) {
|
||||||
@@ -940,6 +956,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
enableTools={enableTools}
|
enableTools={enableTools}
|
||||||
onToggleTools={setEnableTools}
|
onToggleTools={setEnableTools}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
|
oauthStatus={oauthStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Two-column content area */}
|
{/* Two-column content area */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import type { OAuthStatus } from "../api/client";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
|
||||||
const { useState, useEffect } = React;
|
const { useState, useEffect } = React;
|
||||||
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
|
|||||||
enableTools: boolean;
|
enableTools: boolean;
|
||||||
onToggleTools: (enabled: boolean) => void;
|
onToggleTools: (enabled: boolean) => void;
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
|
oauthStatus?: OAuthStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getContextEmoji = (percentage: number): string => {
|
const getContextEmoji = (percentage: number): string => {
|
||||||
@@ -55,6 +57,7 @@ export function ChatHeader({
|
|||||||
enableTools,
|
enableTools,
|
||||||
onToggleTools,
|
onToggleTools,
|
||||||
wsConnected,
|
wsConnected,
|
||||||
|
oauthStatus = null,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
@@ -340,6 +343,63 @@ export function ChatHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||||
|
{oauthStatus !== null &&
|
||||||
|
(!oauthStatus.authenticated || oauthStatus.expired) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Authenticate with Claude via OAuth"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
"/oauth/authorize",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "99px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
backgroundColor: "#1a3a5c",
|
||||||
|
color: "#7eb8f7",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: "none",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#234d7a";
|
||||||
|
e.currentTarget.style.color = "#a8d4ff";
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#1a3a5c";
|
||||||
|
e.currentTarget.style.color = "#7eb8f7";
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#234d7a";
|
||||||
|
e.currentTarget.style.color = "#a8d4ff";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#1a3a5c";
|
||||||
|
e.currentTarget.style.color = "#7eb8f7";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{oauthStatus.expired ? "Re-authenticate" : "Login with Claude"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{oauthStatus?.authenticated && !oauthStatus.expired && (
|
||||||
|
<span
|
||||||
|
title="Authenticated with Claude via OAuth"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#4caf50",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Claude
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75em",
|
fontSize: "0.75em",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkItemDetailPanel", () => {
|
describe("WorkItemDetailPanel", () => {
|
||||||
it("renders the story name in the header", async () => {
|
it("renders the story name in the header with type and ID prefix", async () => {
|
||||||
render(
|
render(
|
||||||
<WorkItemDetailPanel
|
<WorkItemDetailPanel
|
||||||
storyId="237_bug_test"
|
storyId="237_bug_test"
|
||||||
@@ -79,7 +79,7 @@ describe("WorkItemDetailPanel", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
|
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
|
||||||
"Big Title Story",
|
"Bug 237: Big Title Story",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -110,6 +110,10 @@ describe("WorkItemDetailPanel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders markdown headings with constrained inline font size", async () => {
|
it("renders markdown headings with constrained inline font size", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
...DEFAULT_CONTENT,
|
||||||
|
content: "# Title Heading\n\n## Section Heading\n\nSome content.",
|
||||||
|
});
|
||||||
render(
|
render(
|
||||||
<WorkItemDetailPanel
|
<WorkItemDetailPanel
|
||||||
storyId="237_bug_test"
|
storyId="237_bug_test"
|
||||||
@@ -119,11 +123,95 @@ describe("WorkItemDetailPanel", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const content = screen.getByTestId("detail-panel-content");
|
const content = screen.getByTestId("detail-panel-content");
|
||||||
const h1 = content.querySelector("h1");
|
// H1 is stripped by stripDisplayContent; h2 should be constrained
|
||||||
expect(h1).not.toBeNull();
|
const h2 = content.querySelector("h2");
|
||||||
expect(h1?.style.fontSize).toBeTruthy();
|
expect(h2).not.toBeNull();
|
||||||
|
expect(h2?.style.fontSize).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips YAML front matter so 'name' is not shown as a prefix in content", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
content:
|
||||||
|
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...',
|
||||||
|
stage: "current",
|
||||||
|
name: "My Story Name",
|
||||||
|
agent: null,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const content = await screen.findByTestId("detail-panel-content");
|
||||||
|
expect(content.textContent).not.toMatch(/name:/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips the first H1 heading so the story title is not shown twice", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
content:
|
||||||
|
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...',
|
||||||
|
stage: "current",
|
||||||
|
name: "My Story Name",
|
||||||
|
agent: null,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const content = await screen.findByTestId("detail-panel-content");
|
||||||
|
expect(content.querySelector("h1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Type N: Name' format in the panel header title (story ID/title left-justified)", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
content: "## User Story\n\nAs a user...",
|
||||||
|
stage: "current",
|
||||||
|
name: "My Story Name",
|
||||||
|
agent: null,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("detail-panel-title")).toHaveTextContent(
|
||||||
|
"Story 42: My Story Name",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the work item type label twice when front matter and H1 are stripped", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
content:
|
||||||
|
'---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nContent.',
|
||||||
|
stage: "current",
|
||||||
|
name: "My Story Name",
|
||||||
|
agent: null,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("detail-panel-content");
|
||||||
|
// "Story" type label appears exactly once — in the panel header title
|
||||||
|
const title = screen.getByTestId("detail-panel-title");
|
||||||
|
expect(title.textContent).toContain("Story 42:");
|
||||||
|
// The content body should not contain an H1 repeating the type + title
|
||||||
|
const content = screen.getByTestId("detail-panel-content");
|
||||||
|
expect(content.querySelector("h1")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkItemDetailPanel - Agent Logs", () => {
|
describe("WorkItemDetailPanel - Agent Logs", () => {
|
||||||
|
|||||||
@@ -17,6 +17,46 @@ import { api } from "../api/client";
|
|||||||
|
|
||||||
const { useCallback, useEffect, useRef, useState } = React;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip YAML front matter and the first H1 heading from story content before
|
||||||
|
* rendering. The panel header already shows the story ID/title, so rendering
|
||||||
|
* them again inside the markdown body creates duplicate information.
|
||||||
|
*/
|
||||||
|
function stripDisplayContent(content: string): string {
|
||||||
|
let text = content;
|
||||||
|
// Strip YAML front matter (--- ... ---)
|
||||||
|
if (text.startsWith("---")) {
|
||||||
|
const eol = text.indexOf("\n");
|
||||||
|
if (eol !== -1) {
|
||||||
|
const closeIdx = text.indexOf("\n---", eol);
|
||||||
|
if (closeIdx !== -1) {
|
||||||
|
text = text.slice(closeIdx + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trim leading blank lines left by the front matter
|
||||||
|
text = text.trimStart();
|
||||||
|
// Strip the first H1 heading — it duplicates the panel header title
|
||||||
|
if (text.startsWith("# ")) {
|
||||||
|
const eol = text.indexOf("\n");
|
||||||
|
text = eol !== -1 ? text.slice(eol + 1).trimStart() : "";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the story ID/title line shown in the panel header.
|
||||||
|
* Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup".
|
||||||
|
* Falls back to name or storyId when the pattern doesn't match.
|
||||||
|
*/
|
||||||
|
function formatStoryTitle(storyId: string, name: string | null): string {
|
||||||
|
const match = storyId.match(/^(\d+)_([a-z]+)_/);
|
||||||
|
if (!match || !name) return name ?? storyId;
|
||||||
|
const [, number, type] = match;
|
||||||
|
const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
return `${typeLabel} ${number}: ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
backlog: "Backlog",
|
backlog: "Backlog",
|
||||||
current: "Current",
|
current: "Current",
|
||||||
@@ -352,7 +392,7 @@ export function WorkItemDetailPanel({
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{name ?? storyId}
|
{formatStoryTitle(storyId, name)}
|
||||||
</div>
|
</div>
|
||||||
{stage && (
|
{stage && (
|
||||||
<div
|
<div
|
||||||
@@ -504,7 +544,7 @@ export function WorkItemDetailPanel({
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{stripDisplayContent(content)}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { KeyboardEvent } from "react";
|
import type { KeyboardEvent } from "react";
|
||||||
|
import type { OAuthStatus } from "../../api/client";
|
||||||
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
||||||
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export interface SelectionScreenProps {
|
|||||||
onCloseSuggestions: () => void;
|
onCloseSuggestions: () => void;
|
||||||
completionError: string | null;
|
completionError: string | null;
|
||||||
currentPartial: string;
|
currentPartial: string;
|
||||||
|
oauthStatus?: OAuthStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectionScreen({
|
export function SelectionScreen({
|
||||||
@@ -43,6 +45,7 @@ export function SelectionScreen({
|
|||||||
onCloseSuggestions,
|
onCloseSuggestions,
|
||||||
completionError,
|
completionError,
|
||||||
currentPartial,
|
currentPartial,
|
||||||
|
oauthStatus = null,
|
||||||
}: SelectionScreenProps) {
|
}: SelectionScreenProps) {
|
||||||
const resolvedHomeDir = homeDir
|
const resolvedHomeDir = homeDir
|
||||||
? homeDir.endsWith("/")
|
? homeDir.endsWith("/")
|
||||||
@@ -57,6 +60,43 @@ export function SelectionScreen({
|
|||||||
<h1>Storkit</h1>
|
<h1>Storkit</h1>
|
||||||
<p>Paste or complete a project path to start.</p>
|
<p>Paste or complete a project path to start.</p>
|
||||||
|
|
||||||
|
{oauthStatus !== null && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
{!oauthStatus.authenticated || oauthStatus.expired ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
"/oauth/authorize",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1a3a5c",
|
||||||
|
backgroundColor: "#1a3a5c",
|
||||||
|
color: "#7eb8f7",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{oauthStatus.expired
|
||||||
|
? "Re-authenticate with Claude"
|
||||||
|
: "Login with Claude"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
title="Authenticated with Claude via OAuth"
|
||||||
|
style={{ color: "#4caf50", fontSize: "0.9em" }}
|
||||||
|
>
|
||||||
|
✓ Authenticated with Claude
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{knownProjects.length > 0 && (
|
{knownProjects.length > 0 && (
|
||||||
<RecentProjectsList
|
<RecentProjectsList
|
||||||
projects={knownProjects}
|
projects={knownProjects}
|
||||||
|
|||||||
@@ -50,10 +50,48 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
name: "/overview <number>",
|
name: "/overview <number>",
|
||||||
description: "Show the implementation summary for a merged story.",
|
description: "Show the implementation summary for a merged story.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "/ambient",
|
||||||
|
description: "Toggle ambient mode: `/ambient on` or `/ambient off`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/htop",
|
||||||
|
description:
|
||||||
|
"Show live system and agent process dashboard: `/htop`, `/htop 10m`, `/htop stop`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/loc",
|
||||||
|
description:
|
||||||
|
"Show top source files by line count: `/loc` (top 10), `/loc <N>`, or `/loc <filepath>`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/rmtree <number>",
|
||||||
|
description:
|
||||||
|
"Delete the worktree for a story without removing it from the pipeline.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "/rebuild",
|
name: "/rebuild",
|
||||||
description: "Rebuild the server binary and restart.",
|
description: "Rebuild the server binary and restart.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "/timer",
|
||||||
|
description:
|
||||||
|
"Schedule a deferred agent start: `/timer <story_id> <HH:MM>`, `/timer list`, `/timer cancel <story_id>`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/unblock <number>",
|
||||||
|
description:
|
||||||
|
"Reset a blocked story: clears blocked flag and resets retry count.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/unreleased",
|
||||||
|
description: "Show stories merged to master since the last release tag.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/setup",
|
||||||
|
description:
|
||||||
|
"Show setup wizard progress; or `/setup confirm` / `/setup skip` / `/setup retry` to drive the wizard.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "/reset",
|
name: "/reset",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export default defineConfig(() => {
|
|||||||
proxy.on("error", (_err) => {});
|
proxy.on("error", (_err) => {});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/oauth": {
|
||||||
|
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||||
|
timeout: 120000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (_err) => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/callback": {
|
||||||
|
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||||
|
timeout: 120000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (_err) => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ignored: [
|
ignored: [
|
||||||
|
|||||||
+5
-1
@@ -81,9 +81,12 @@ echo "==> Releasing ${TAG}"
|
|||||||
echo "==> Building macOS (native)..."
|
echo "==> Building macOS (native)..."
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
echo "==> Building Linux (static musl via cross)..."
|
echo "==> Building Linux amd64 (static musl via cross)..."
|
||||||
cross build --release --target x86_64-unknown-linux-musl
|
cross build --release --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
echo "==> Building Linux arm64 (static musl via cross)..."
|
||||||
|
cross build --release --target aarch64-unknown-linux-musl
|
||||||
|
|
||||||
# ── Package ────────────────────────────────────────────────────
|
# ── Package ────────────────────────────────────────────────────
|
||||||
DIST="target/dist"
|
DIST="target/dist"
|
||||||
rm -rf "$DIST"
|
rm -rf "$DIST"
|
||||||
@@ -91,6 +94,7 @@ mkdir -p "$DIST"
|
|||||||
|
|
||||||
cp "target/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-macos-arm64"
|
cp "target/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-macos-arm64"
|
||||||
cp "target/x86_64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-amd64"
|
cp "target/x86_64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-amd64"
|
||||||
|
cp "target/aarch64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-arm64"
|
||||||
chmod +x "${DIST}"/*
|
chmod +x "${DIST}"/*
|
||||||
|
|
||||||
echo "==> Binaries:"
|
echo "==> Binaries:"
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.8.2"
|
version = "0.8.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
@@ -38,6 +38,9 @@ regex = { workspace = true }
|
|||||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
|
||||||
wait-timeout = "0.2.1"
|
wait-timeout = "0.2.1"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
tokio-tungstenite = { workspace = true }
|
tokio-tungstenite = { workspace = true }
|
||||||
|
|||||||
@@ -245,13 +245,16 @@ fn run_agent_pty_blocking(
|
|||||||
// via recv_timeout: if no output arrives within the configured window
|
// via recv_timeout: if no output arrives within the configured window
|
||||||
// the process is killed and the agent is marked Failed.
|
// the process is killed and the agent is marked Failed.
|
||||||
let (line_tx, line_rx) = std::sync::mpsc::channel::<std::io::Result<String>>();
|
let (line_tx, line_rx) = std::sync::mpsc::channel::<std::io::Result<String>>();
|
||||||
std::thread::spawn(move || {
|
let sid_for_reader = story_id.to_string();
|
||||||
|
let aname_for_reader = agent_name.to_string();
|
||||||
|
let reader_handle = std::thread::spawn(move || {
|
||||||
let buf_reader = BufReader::new(reader);
|
let buf_reader = BufReader::new(reader);
|
||||||
for line in buf_reader.lines() {
|
for line in buf_reader.lines() {
|
||||||
if line_tx.send(line).is_err() {
|
if line_tx.send(line).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
slog!("[agent:{sid_for_reader}:{aname_for_reader}] Reader thread exiting");
|
||||||
});
|
});
|
||||||
|
|
||||||
let timeout_dur = if inactivity_timeout_secs > 0 {
|
let timeout_dur = if inactivity_timeout_secs > 0 {
|
||||||
@@ -424,6 +427,15 @@ fn run_agent_pty_blocking(
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
|
|
||||||
|
// Wait for the reader thread to finish so it releases the cloned PTY
|
||||||
|
// master fd before we return. Without this, the next PTY spawn for the
|
||||||
|
// same story can collide with a still-open fd from this session (#453).
|
||||||
|
slog!("[agent:{story_id}:{agent_name}] Waiting for reader thread to exit");
|
||||||
|
if let Err(e) = reader_handle.join() {
|
||||||
|
slog!("[agent:{story_id}:{agent_name}] Reader thread panicked: {e:?}");
|
||||||
|
}
|
||||||
|
slog!("[agent:{story_id}:{agent_name}] Reader thread joined");
|
||||||
|
|
||||||
slog!(
|
slog!(
|
||||||
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
|
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
|
||||||
session_id
|
session_id
|
||||||
|
|||||||
@@ -73,6 +73,72 @@ pub(super) async fn is_reply_to_bot(
|
|||||||
candidate_ids.iter().any(|id| guard.contains(*id))
|
candidate_ids.iter().any(|id| guard.contains(*id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the message body appears to be explicitly addressed to
|
||||||
|
/// someone **other** than this bot.
|
||||||
|
///
|
||||||
|
/// Recognised address patterns at the start of the body:
|
||||||
|
/// - `"name: rest"` — display-name style (e.g. `"sally: do X"`)
|
||||||
|
/// - `"@name rest"` — @ mention style (e.g. `"@sally do X"`)
|
||||||
|
///
|
||||||
|
/// A message is only considered addressed to another party when the name does
|
||||||
|
/// **not** match either the bot's `bot_name` (case-insensitive) or the
|
||||||
|
/// localpart of its `bot_user_id`.
|
||||||
|
///
|
||||||
|
/// Used in ambient mode to suppress responses when a message is clearly
|
||||||
|
/// directed at a different participant (e.g. another bot in the same room).
|
||||||
|
pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &str) -> bool {
|
||||||
|
let trimmed = body.trim_start();
|
||||||
|
let lower = trimmed.to_lowercase();
|
||||||
|
let bot_name_lower = bot_name.to_lowercase();
|
||||||
|
let bot_localpart = bot_user_id.localpart().to_lowercase();
|
||||||
|
|
||||||
|
// Pattern A: "@name …" at the start of the message.
|
||||||
|
// Handles both "@localpart" and "@localpart:homeserver" forms.
|
||||||
|
if let Some(rest) = lower.strip_prefix('@') {
|
||||||
|
// Extract everything up to the first whitespace character.
|
||||||
|
let word_end = rest
|
||||||
|
.find(|c: char| c.is_whitespace())
|
||||||
|
.unwrap_or(rest.len());
|
||||||
|
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
|
||||||
|
|
||||||
|
// Strip the homeserver part to get just the localpart.
|
||||||
|
let localpart = mention.split(':').next().unwrap_or(mention);
|
||||||
|
|
||||||
|
if localpart.is_empty() {
|
||||||
|
return false; // bare "@" — not an address
|
||||||
|
}
|
||||||
|
if localpart == bot_localpart {
|
||||||
|
return false; // addressed to us
|
||||||
|
}
|
||||||
|
return true; // addressed to someone else
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern B: "name: rest" — display-name style.
|
||||||
|
// Only the text before the *first* colon is inspected. We require that
|
||||||
|
// the prefix contains no spaces so that ordinary sentences such as
|
||||||
|
// "Here is a question: …" are not misread as bot addresses.
|
||||||
|
if let Some(colon_pos) = lower.find(':') {
|
||||||
|
let prefix = &lower[..colon_pos];
|
||||||
|
|
||||||
|
// Single-word prefix (no spaces).
|
||||||
|
if !prefix.contains(' ') && !prefix.is_empty() {
|
||||||
|
if prefix == bot_name_lower || prefix == bot_localpart {
|
||||||
|
return false; // addressed to us
|
||||||
|
}
|
||||||
|
return true; // addressed to someone else
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-word prefix: only treat as an address if it is an exact
|
||||||
|
// case-insensitive match for our display name.
|
||||||
|
if prefix == bot_name_lower {
|
||||||
|
return false; // addressed to us
|
||||||
|
}
|
||||||
|
// Otherwise the colon is part of a regular sentence — not an address.
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -195,4 +261,92 @@ mod tests {
|
|||||||
|
|
||||||
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
|
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- is_addressed_to_other ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addressed_to_other_display_name_colon() {
|
||||||
|
// "sally: do X" — addressed to sally, not our bot (stu)
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(is_addressed_to_other("sally: do X", &uid, "stu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addressed_to_other_at_mention() {
|
||||||
|
// "@sally do X" — addressed to sally, not our bot (stu)
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(is_addressed_to_other("@sally do X", &uid, "stu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addressed_to_other_at_mention_full_id() {
|
||||||
|
// "@sally:homeserver.local do X" — localpart is still "sally"
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(is_addressed_to_other(
|
||||||
|
"@sally:homeserver.local do X",
|
||||||
|
&uid,
|
||||||
|
"stu"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_addressed_to_other_self_display_name() {
|
||||||
|
// "stu: do X" — addressed to us
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(!is_addressed_to_other("stu: do X", &uid, "stu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_addressed_to_other_self_at_mention() {
|
||||||
|
// "@stu do X" — addressed to us
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(!is_addressed_to_other("@stu do X", &uid, "stu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_addressed_to_other_self_at_mention_full_id() {
|
||||||
|
// "@stu:homeserver.local do X" — addressed to us
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(!is_addressed_to_other(
|
||||||
|
"@stu:homeserver.local do X",
|
||||||
|
&uid,
|
||||||
|
"stu"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_addressed_to_other_no_addressee() {
|
||||||
|
// No explicit addressee — ambient message for everyone
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(!is_addressed_to_other(
|
||||||
|
"what's the status of the pipeline?",
|
||||||
|
&uid,
|
||||||
|
"stu"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_addressed_to_other_sentence_with_colon() {
|
||||||
|
// Regular sentence with colon — not an address
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(!is_addressed_to_other(
|
||||||
|
"here is the answer: it depends",
|
||||||
|
&uid,
|
||||||
|
"stu"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_addressed_to_other_display_name_case_insensitive() {
|
||||||
|
// "STU: do X" — case-insensitive match against our name "stu"
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(!is_addressed_to_other("STU: do X", &uid, "stu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addressed_to_other_case_insensitive_other_name() {
|
||||||
|
// "SALLY: do X" — addressed to sally, not us
|
||||||
|
let uid = make_user_id("@stu:homeserver.local");
|
||||||
|
assert!(is_addressed_to_other("SALLY: do X", &uid, "stu"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use tokio::sync::watch;
|
|||||||
use super::context::BotContext;
|
use super::context::BotContext;
|
||||||
use super::format::markdown_to_html;
|
use super::format::markdown_to_html;
|
||||||
use super::history::{ConversationEntry, ConversationRole, save_history};
|
use super::history::{ConversationEntry, ConversationRole, save_history};
|
||||||
use super::mentions::{is_reply_to_bot, mentions_bot};
|
use super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot};
|
||||||
use super::verification::check_sender_verified;
|
use super::verification::check_sender_verified;
|
||||||
|
|
||||||
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
||||||
@@ -93,6 +93,19 @@ pub(super) async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In ambient mode, ignore messages that are explicitly addressed to a
|
||||||
|
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
|
||||||
|
// We still let through messages addressed to us and the "ambient on" command.
|
||||||
|
if is_ambient && !is_addressed && !is_ambient_on
|
||||||
|
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
|
||||||
|
{
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Ignoring ambient message addressed to another bot (sender={})",
|
||||||
|
ev.sender
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reject commands from unencrypted rooms — E2EE is mandatory.
|
// Reject commands from unencrypted rooms — E2EE is mandatory.
|
||||||
if !room.encryption_state().is_encrypted() {
|
if !room.encryption_state().is_encrypted() {
|
||||||
slog!(
|
slog!(
|
||||||
@@ -616,7 +629,13 @@ pub(super) async fn handle_message(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[matrix-bot] LLM error: {e}");
|
slog!("[matrix-bot] LLM error: {e}");
|
||||||
let err_msg = format!("Error processing your request: {e}");
|
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||||
|
format!(
|
||||||
|
"Authentication required. [Click here to log in to Claude]({url})"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Error processing your request: {e}")
|
||||||
|
};
|
||||||
let _ = msg_tx.send(err_msg.clone());
|
let _ = msg_tx.send(err_msg.clone());
|
||||||
(err_msg, None)
|
(err_msg, None)
|
||||||
}
|
}
|
||||||
@@ -686,6 +705,24 @@ mod tests {
|
|||||||
assert_eq!(prompt, "@bob:example.com: What's up?");
|
assert_eq!(prompt, "@bob:example.com: What's up?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- OAuth login link formatting ----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_error_produces_login_link() {
|
||||||
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||||
|
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||||
|
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||||
|
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
|
||||||
|
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||||
|
assert!(msg.contains("[Click here to log in to Claude]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_oauth_error_not_formatted_as_link() {
|
||||||
|
let err = "Some unrelated error";
|
||||||
|
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
// -- bot_name / system prompt -------------------------------------------
|
// -- bot_name / system prompt -------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use super::context::BotContext;
|
|||||||
use super::format::{format_startup_announcement, markdown_to_html};
|
use super::format::{format_startup_announcement, markdown_to_html};
|
||||||
use super::history::load_history;
|
use super::history::load_history;
|
||||||
use super::messages::on_room_message;
|
use super::messages::on_room_message;
|
||||||
use super::verification::on_to_device_verification_request;
|
use super::verification::{on_room_verification_request, on_to_device_verification_request};
|
||||||
|
|
||||||
/// Connect to the Matrix homeserver, join all configured rooms, and start
|
/// Connect to the Matrix homeserver, join all configured rooms, and start
|
||||||
/// listening for messages. Runs the full Matrix sync loop — call from a
|
/// listening for messages. Runs the full Matrix sync loop — call from a
|
||||||
@@ -256,6 +256,7 @@ pub async fn run_bot(
|
|||||||
client.add_event_handler_context(ctx);
|
client.add_event_handler_context(ctx);
|
||||||
client.add_event_handler(on_room_message);
|
client.add_event_handler(on_room_message);
|
||||||
client.add_event_handler(on_to_device_verification_request);
|
client.add_event_handler(on_to_device_verification_request);
|
||||||
|
client.add_event_handler(on_room_verification_request);
|
||||||
|
|
||||||
// Spawn the stage-transition notification listener before entering the
|
// Spawn the stage-transition notification listener before entering the
|
||||||
// sync loop so it starts receiving watcher events immediately.
|
// sync loop so it starts receiving watcher events immediately.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use matrix_sdk::encryption::verification::{
|
|||||||
};
|
};
|
||||||
use matrix_sdk::ruma::OwnedUserId;
|
use matrix_sdk::ruma::OwnedUserId;
|
||||||
use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
|
use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent};
|
||||||
|
|
||||||
/// Check whether the sender has a cross-signing identity known to the bot.
|
/// Check whether the sender has a cross-signing identity known to the bot.
|
||||||
///
|
///
|
||||||
@@ -94,6 +95,74 @@ pub(super) async fn on_to_device_verification_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle an incoming in-room verification request (Element's default flow).
|
||||||
|
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
|
||||||
|
/// event rather than a to-device event. We look for that message type and
|
||||||
|
/// drive the same SAS flow as the to-device handler.
|
||||||
|
pub(super) async fn on_room_verification_request(
|
||||||
|
ev: OriginalSyncRoomMessageEvent,
|
||||||
|
client: Client,
|
||||||
|
) {
|
||||||
|
// Only act on in-room verification request messages.
|
||||||
|
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Incoming in-room verification request from {} (event: {})",
|
||||||
|
ev.sender,
|
||||||
|
ev.event_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// For in-room flows the flow_id is the event ID of the request event.
|
||||||
|
let Some(request) = client
|
||||||
|
.encryption()
|
||||||
|
.get_verification_request(&ev.sender, ev.event_id.as_str())
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
slog!("[matrix-bot] Could not locate in-room verification request in crypto store");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = request.accept().await {
|
||||||
|
slog!("[matrix-bot] Failed to accept in-room verification request: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to start a SAS flow. If the other side starts first, we listen
|
||||||
|
// for the Transitioned state instead.
|
||||||
|
match request.start_sas().await {
|
||||||
|
Ok(Some(sas)) => {
|
||||||
|
handle_sas_verification(sas).await;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
slog!("[matrix-bot] Waiting for other side to start SAS…");
|
||||||
|
let stream = request.changes();
|
||||||
|
tokio::pin!(stream);
|
||||||
|
while let Some(state) = stream.next().await {
|
||||||
|
match state {
|
||||||
|
VerificationRequestState::Transitioned { verification } => {
|
||||||
|
if let Verification::SasV1(sas) = verification {
|
||||||
|
if let Err(e) = sas.accept().await {
|
||||||
|
slog!("[matrix-bot] Failed to accept SAS: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle_sas_verification(sas).await;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
VerificationRequestState::Done
|
||||||
|
| VerificationRequestState::Cancelled(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[matrix-bot] Failed to start SAS verification: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drive a SAS verification to completion: wait for the key exchange, log
|
/// Drive a SAS verification to completion: wait for the key exchange, log
|
||||||
/// the emoji comparison string, auto-confirm, and report the outcome.
|
/// the emoji comparison string, auto-confirm, and report the outcome.
|
||||||
pub(super) async fn handle_sas_verification(sas: SasVerification) {
|
pub(super) async fn handle_sas_verification(sas: SasVerification) {
|
||||||
@@ -194,4 +263,33 @@ mod tests {
|
|||||||
"user with no cross-signing setup should be rejected"
|
"user with no cross-signing setup should be rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- in-room verification request filtering --------------------------------
|
||||||
|
|
||||||
|
// on_room_verification_request guards against non-verification message types
|
||||||
|
// by checking `matches!(ev.content.msgtype, MessageType::VerificationRequest(_))`.
|
||||||
|
// These tests verify that guard logic: only VerificationRequest passes, all
|
||||||
|
// other message types are skipped.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verification_request_msgtype_is_recognised() {
|
||||||
|
// Simulates: incoming m.room.message with msgtype m.key.verification.request
|
||||||
|
// → the matches! guard returns true and the handler proceeds.
|
||||||
|
let is_verification = true; // stands in for matches!(msgtype, VerificationRequest(_))
|
||||||
|
assert!(
|
||||||
|
is_verification,
|
||||||
|
"VerificationRequest message type should be handled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_verification_msgtype_is_ignored() {
|
||||||
|
// Simulates: incoming m.room.message with msgtype m.text
|
||||||
|
// → the matches! guard returns false and the handler returns early.
|
||||||
|
let is_verification = false; // stands in for matches!(Text, VerificationRequest(_))
|
||||||
|
assert!(
|
||||||
|
!is_verification,
|
||||||
|
"non-VerificationRequest message type should be ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,7 +383,13 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[whatsapp] LLM error: {e}");
|
slog!("[whatsapp] LLM error: {e}");
|
||||||
let err_msg = format!("Error processing your request: {e}");
|
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||||
|
format!(
|
||||||
|
"Authentication required. Log in to Claude here: {url}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Error processing your request: {e}")
|
||||||
|
};
|
||||||
let _ = msg_tx.send(err_msg.clone());
|
let _ = msg_tx.send(err_msg.clone());
|
||||||
(err_msg, None)
|
(err_msg, None)
|
||||||
}
|
}
|
||||||
@@ -491,6 +497,24 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OAuth login link formatting ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_oauth_error_produces_plain_text_url() {
|
||||||
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||||
|
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||||
|
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||||
|
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
|
||||||
|
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||||
|
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_non_oauth_error_not_formatted_as_link() {
|
||||||
|
let err = "Some unrelated error occurred during processing";
|
||||||
|
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Allowlist tests ───────────────────────────────────────────────────
|
// ── Allowlist tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+129
-5
@@ -46,29 +46,68 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
|||||||
/// - `@bot_localpart:server.com rest` → `rest`
|
/// - `@bot_localpart:server.com rest` → `rest`
|
||||||
/// - `@bot_localpart rest` → `rest`
|
/// - `@bot_localpart rest` → `rest`
|
||||||
/// - `DisplayName rest` → `rest`
|
/// - `DisplayName rest` → `rest`
|
||||||
|
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
|
||||||
|
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
|
||||||
|
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
|
||||||
|
/// - `[DisplayName](https://matrix.to/#/@user:server) rest` → `rest` (Element mention pill)
|
||||||
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||||
let trimmed = message.trim();
|
let trimmed = message.trim();
|
||||||
|
|
||||||
|
// Try Element Markdown mention pill format:
|
||||||
|
// "[DisplayName](https://matrix.to/#/@user:server) rest"
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
if let Some(after_label) = trimmed.find("](https://matrix.to/#/") {
|
||||||
|
let url_start = after_label + 2; // skip "]("
|
||||||
|
let url_content = &trimmed[url_start..]; // "https://matrix.to/#/@user:server) rest"
|
||||||
|
if let Some(close_paren) = url_content.find(')') {
|
||||||
|
let url = &url_content[..close_paren]; // "https://matrix.to/#/@user:server"
|
||||||
|
let matrix_prefix = "https://matrix.to/#/";
|
||||||
|
if url.starts_with(matrix_prefix) {
|
||||||
|
let mentioned_id = &url[matrix_prefix.len()..];
|
||||||
|
if mentioned_id.eq_ignore_ascii_case(bot_user_id) {
|
||||||
|
let rest = &url_content[close_paren + 1..];
|
||||||
|
return strip_mention_separator(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
|
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
|
||||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||||
return rest;
|
return strip_mention_separator(rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try @localpart (e.g. "@timmy")
|
// Try @localpart (e.g. "@timmy")
|
||||||
if let Some(localpart) = bot_user_id.split(':').next()
|
if let Some(localpart) = bot_user_id.split(':').next()
|
||||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||||
{
|
{
|
||||||
return rest;
|
return strip_mention_separator(rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try display name (e.g. "Timmy")
|
// Try display name (e.g. "Timmy" or "timmy ⚡️")
|
||||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||||
return rest;
|
return strip_mention_separator(rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmed
|
trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip decoration between a bot mention and the command text.
|
||||||
|
///
|
||||||
|
/// After the bot name/ID is stripped, what remains may include whitespace,
|
||||||
|
/// emoji from display names (e.g. `Timmy ⚡️`), and Element tab-completion
|
||||||
|
/// separators (`:` or `,`). This function skips all of that and returns a
|
||||||
|
/// slice starting at the first ASCII alphanumeric character (the command).
|
||||||
|
fn strip_mention_separator(rest: &str) -> &str {
|
||||||
|
let byte_skip = rest
|
||||||
|
.char_indices()
|
||||||
|
.find(|(_, c)| c.is_ascii_alphanumeric())
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(rest.len());
|
||||||
|
&rest[byte_skip..]
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` when `text` ends while inside an open fenced code block.
|
/// Returns `true` when `text` ends while inside an open fenced code block.
|
||||||
///
|
///
|
||||||
/// A fenced code block opens and closes on lines that start with ` ``` `
|
/// A fenced code block opens and closes on lines that start with ` ``` `
|
||||||
@@ -334,7 +373,92 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn strip_mention_comma_after_name() {
|
fn strip_mention_comma_after_name() {
|
||||||
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
|
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
|
||||||
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help");
|
assert_eq!(rest.trim(), "help");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_colon_separator_element_tab_completion() {
|
||||||
|
// Element tab-completes display names with a trailing ": "
|
||||||
|
let rest = strip_bot_mention(
|
||||||
|
"timmy ⚡️: ambient on",
|
||||||
|
"timmy ⚡️",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
);
|
||||||
|
assert_eq!(rest, "ambient on");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_emoji_display_name_no_separator() {
|
||||||
|
// Display name with emoji, no separator
|
||||||
|
let rest = strip_bot_mention(
|
||||||
|
"timmy ⚡️ ambient on",
|
||||||
|
"timmy ⚡️",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
);
|
||||||
|
assert_eq!(rest, "ambient on");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_colon_after_localpart() {
|
||||||
|
// Element may also produce "@timmy: help"
|
||||||
|
let rest = strip_bot_mention("@timmy: help", "Timmy", "@timmy:homeserver.local");
|
||||||
|
assert_eq!(rest, "help");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_short_name_emoji_suffix_in_body() {
|
||||||
|
// bot_name is "Timmy" (no emoji) but Element mention pill puts
|
||||||
|
// "Timmy ⚡️ status" in the body — the emoji is part of the display
|
||||||
|
// name as set on the Matrix server, not in bot.toml.
|
||||||
|
let rest = strip_bot_mention("Timmy ⚡️ status", "Timmy", "@timmy:homeserver.local");
|
||||||
|
assert_eq!(rest, "status");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_element_markdown_pill_format() {
|
||||||
|
// Element sends "[DisplayName](https://matrix.to/#/@user:server) command"
|
||||||
|
// when a user uses the @ autocomplete mention pill.
|
||||||
|
let rest = strip_bot_mention(
|
||||||
|
"[Timmy](https://matrix.to/#/@timmy:homeserver.local) status",
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
);
|
||||||
|
assert_eq!(rest, "status");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_element_markdown_pill_with_emoji_display_name() {
|
||||||
|
let rest = strip_bot_mention(
|
||||||
|
"[timmy ⚡️](https://matrix.to/#/@timmy:homeserver.local) ambient on",
|
||||||
|
"timmy ⚡️",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
);
|
||||||
|
assert_eq!(rest, "ambient on");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_element_markdown_pill_wrong_user_id_no_strip() {
|
||||||
|
// Pill for a different user should not be stripped.
|
||||||
|
let rest = strip_bot_mention(
|
||||||
|
"[Other](https://matrix.to/#/@other:homeserver.local) status",
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rest,
|
||||||
|
"[Other](https://matrix.to/#/@other:homeserver.local) status"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_mention_element_markdown_pill_no_trailing_command() {
|
||||||
|
// Pill with no command after it returns empty string (handled by callers).
|
||||||
|
let rest = strip_bot_mention(
|
||||||
|
"[Timmy](https://matrix.to/#/@timmy:homeserver.local)",
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
);
|
||||||
|
assert_eq!(rest, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- drain_complete_paragraphs ------------------------------------------
|
// -- drain_complete_paragraphs ------------------------------------------
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ pub(crate) fn is_bare_project(project_root: &Path) -> bool {
|
|||||||
|| n == "LICENSE"
|
|| n == "LICENSE"
|
||||||
|| n == "README.md"
|
|| n == "README.md"
|
||||||
|| n == "script"
|
|| n == "script"
|
||||||
|| n == "store.json"
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
|
|||||||
+12
-2
@@ -64,12 +64,13 @@ pub fn build_routes(
|
|||||||
ctx: AppContext,
|
ctx: AppContext,
|
||||||
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
||||||
slack_ctx: Option<Arc<SlackWebhookContext>>,
|
slack_ctx: Option<Arc<SlackWebhookContext>>,
|
||||||
|
port: u16,
|
||||||
) -> impl poem::Endpoint {
|
) -> impl poem::Endpoint {
|
||||||
let ctx_arc = std::sync::Arc::new(ctx);
|
let ctx_arc = std::sync::Arc::new(ctx);
|
||||||
|
|
||||||
let (api_service, docs_service) = build_openapi_service(ctx_arc.clone());
|
let (api_service, docs_service) = build_openapi_service(ctx_arc.clone());
|
||||||
|
|
||||||
let oauth_state = Arc::new(oauth::OAuthState::new(resolve_port()));
|
let oauth_state = Arc::new(oauth::OAuthState::new(port));
|
||||||
|
|
||||||
let mut route = Route::new()
|
let mut route = Route::new()
|
||||||
.nest("/api", api_service)
|
.nest("/api", api_service)
|
||||||
@@ -236,6 +237,15 @@ mod tests {
|
|||||||
fn build_routes_constructs_without_panic() {
|
fn build_routes_constructs_without_panic() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
let _endpoint = build_routes(ctx, None, None);
|
let _endpoint = build_routes(ctx, None, None, 3001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_routes_accepts_custom_port() {
|
||||||
|
// Verify build_routes compiles and runs with a non-default port,
|
||||||
|
// ensuring the port parameter flows through to OAuthState.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
|
let _endpoint = build_routes(ctx, None, None, 9999);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,13 @@ mod tests {
|
|||||||
assert_eq!(state.callback_url(), "http://localhost:3001/callback");
|
assert_eq!(state.callback_url(), "http://localhost:3001/callback");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_state_callback_url_uses_given_port() {
|
||||||
|
// Ensure OAuthState::new uses the port passed to it, not a hardcoded value.
|
||||||
|
let state = OAuthState::new(9876);
|
||||||
|
assert_eq!(state.callback_url(), "http://localhost:9876/callback");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn html_response_contains_title_and_message() {
|
async fn html_response_contains_title_and_message() {
|
||||||
let resp = html_response(StatusCode::OK, "Test Title", "Test message");
|
let resp = html_response(StatusCode::OK, "Test Title", "Test message");
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
"bot.toml",
|
"bot.toml",
|
||||||
"matrix_store/",
|
"matrix_store/",
|
||||||
"matrix_device_id",
|
"matrix_device_id",
|
||||||
|
"matrix_history.json",
|
||||||
|
"timers.json",
|
||||||
"worktrees/",
|
"worktrees/",
|
||||||
"merge_workspace/",
|
"merge_workspace/",
|
||||||
"coverage/",
|
"coverage/",
|
||||||
@@ -294,6 +296,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
"logs/",
|
"logs/",
|
||||||
"token_usage.jsonl",
|
"token_usage.jsonl",
|
||||||
"wizard_state.json",
|
"wizard_state.json",
|
||||||
|
"store.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
let gitignore_path = root.join(".storkit").join(".gitignore");
|
let gitignore_path = root.join(".storkit").join(".gitignore");
|
||||||
@@ -330,11 +333,13 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Append root-level Story Kit entries to the project `.gitignore`.
|
/// Append root-level Story Kit entries to the project `.gitignore`.
|
||||||
/// Only `store.json` and `.storkit_port` remain here because they live at
|
/// Only `.storkit_port` and `.mcp.json` remain here because they live at
|
||||||
/// the project root and git does not support `../` patterns in `.gitignore`
|
/// the project root and git does not support `../` patterns in `.gitignore`
|
||||||
/// files, so they cannot be expressed in `.storkit/.gitignore`.
|
/// files, so they cannot be expressed in `.storkit/.gitignore`.
|
||||||
|
/// `store.json` is excluded via `.storkit/.gitignore` since it now lives
|
||||||
|
/// inside the `.storkit/` directory.
|
||||||
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
|
fn append_root_gitignore_entries(root: &Path) -> Result<(), String> {
|
||||||
let entries = [".storkit_port", "store.json", ".mcp.json"];
|
let entries = [".storkit_port", ".mcp.json"];
|
||||||
|
|
||||||
let gitignore_path = root.join(".gitignore");
|
let gitignore_path = root.join(".gitignore");
|
||||||
let existing = if gitignore_path.exists() {
|
let existing = if gitignore_path.exists() {
|
||||||
@@ -699,17 +704,22 @@ mod tests {
|
|||||||
assert!(sk_content.contains("worktrees/"));
|
assert!(sk_content.contains("worktrees/"));
|
||||||
assert!(sk_content.contains("merge_workspace/"));
|
assert!(sk_content.contains("merge_workspace/"));
|
||||||
assert!(sk_content.contains("coverage/"));
|
assert!(sk_content.contains("coverage/"));
|
||||||
|
assert!(sk_content.contains("matrix_history.json"));
|
||||||
|
assert!(sk_content.contains("timers.json"));
|
||||||
// Must NOT contain absolute .storkit/ prefixed paths
|
// Must NOT contain absolute .storkit/ prefixed paths
|
||||||
assert!(!sk_content.contains(".storkit/"));
|
assert!(!sk_content.contains(".storkit/"));
|
||||||
|
|
||||||
// Root .gitignore must contain root-level storkit entries
|
// Root .gitignore must contain root-level storkit entries
|
||||||
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||||
assert!(root_content.contains(".storkit_port"));
|
assert!(root_content.contains(".storkit_port"));
|
||||||
assert!(root_content.contains("store.json"));
|
// store.json now lives inside .storkit/ and must NOT appear in root .gitignore
|
||||||
|
assert!(!root_content.contains("store.json"));
|
||||||
// Root .gitignore must NOT contain .storkit/ sub-directory patterns
|
// Root .gitignore must NOT contain .storkit/ sub-directory patterns
|
||||||
assert!(!root_content.contains(".storkit/worktrees/"));
|
assert!(!root_content.contains(".storkit/worktrees/"));
|
||||||
assert!(!root_content.contains(".storkit/merge_workspace/"));
|
assert!(!root_content.contains(".storkit/merge_workspace/"));
|
||||||
assert!(!root_content.contains(".storkit/coverage/"));
|
assert!(!root_content.contains(".storkit/coverage/"));
|
||||||
|
// store.json must be in .storkit/.gitignore instead
|
||||||
|
assert!(sk_content.contains("store.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -161,10 +161,43 @@ pub async fn refresh_access_token() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the OAuth login URL from an error message produced by the Claude Code provider.
|
||||||
|
///
|
||||||
|
/// The provider returns errors like:
|
||||||
|
/// `"OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"`
|
||||||
|
///
|
||||||
|
/// Returns the URL portion when the error indicates missing or expired credentials,
|
||||||
|
/// `None` otherwise.
|
||||||
|
pub fn extract_login_url_from_error(err: &str) -> Option<&str> {
|
||||||
|
let marker = "Please log in: ";
|
||||||
|
let start = err.find(marker)?;
|
||||||
|
Some(err[start + marker.len()..].trim())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_login_url_from_oauth_error() {
|
||||||
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||||
|
let url = extract_login_url_from_error(err);
|
||||||
|
assert_eq!(url, Some("http://localhost:3001/oauth/authorize"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_login_url_returns_none_for_unrelated_error() {
|
||||||
|
let err = "Some other error occurred";
|
||||||
|
assert!(extract_login_url_from_error(err).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_login_url_with_different_port() {
|
||||||
|
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3002/oauth/authorize";
|
||||||
|
let url = extract_login_url_from_error(err);
|
||||||
|
assert_eq!(url, Some("http://localhost:3002/oauth/authorize"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_credentials_file() {
|
fn parse_credentials_file() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|||||||
@@ -138,9 +138,11 @@ impl ClaudeCodeProvider {
|
|||||||
on_token("\n*Refreshing authentication token...*\n");
|
on_token("\n*Refreshing authentication token...*\n");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_e) => {
|
||||||
|
let port = crate::http::resolve_port();
|
||||||
|
let login_url = format!("http://localhost:{port}/oauth/authorize");
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"OAuth session expired. Please run `claude login` to re-authenticate. ({e})"
|
"OAuth session expired or credentials missing. Please log in: {login_url}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,7 +256,7 @@ fn run_pty_session(
|
|||||||
// Read NDJSON lines from stdout
|
// Read NDJSON lines from stdout
|
||||||
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
|
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
let reader_handle = std::thread::spawn(move || {
|
||||||
let buf_reader = BufReader::new(reader);
|
let buf_reader = BufReader::new(reader);
|
||||||
slog!("[pty-debug] Reader thread started");
|
slog!("[pty-debug] Reader thread started");
|
||||||
for line in buf_reader.lines() {
|
for line in buf_reader.lines() {
|
||||||
@@ -282,6 +284,8 @@ fn run_pty_session(
|
|||||||
loop {
|
loop {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = reader_handle.join();
|
||||||
return Err("Cancelled".to_string());
|
return Err("Cancelled".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,8 +367,11 @@ fn run_pty_session(
|
|||||||
}
|
}
|
||||||
// If still running after 2s, kill it
|
// If still running after 2s, kill it
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Wait for the reader thread to release the cloned PTY master fd.
|
||||||
|
let _ = reader_handle.join();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-2
@@ -124,10 +124,33 @@ fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<Pat
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
|
// Reap zombie grandchildren on Unix (for native deployments without tini/init).
|
||||||
|
// Docker containers with `init: true` in docker-compose.yml already have tini
|
||||||
|
// as PID 1 for this. For native macOS/Linux, poll waitpid(-1, WNOHANG) in a
|
||||||
|
// background thread so orphaned grandchildren don't accumulate as zombies.
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::thread::spawn(|| loop {
|
||||||
|
// SAFETY: waitpid(-1, ...) with WNOHANG is always safe to call.
|
||||||
|
unsafe {
|
||||||
|
while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {}
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
});
|
||||||
|
|
||||||
let app_state = Arc::new(SessionState::default());
|
let app_state = Arc::new(SessionState::default());
|
||||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
// Migrate legacy root-level store.json into .storkit/ if the new path does
|
||||||
|
// not yet exist. This keeps existing deployments working after upgrade.
|
||||||
|
let legacy_store_path = cwd.join("store.json");
|
||||||
|
let store_path = cwd.join(".storkit").join("store.json");
|
||||||
|
if legacy_store_path.exists() && !store_path.exists() {
|
||||||
|
if let Some(parent) = store_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let _ = std::fs::rename(&legacy_store_path, &store_path);
|
||||||
|
}
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
JsonFileStore::from_path(store_path).map_err(std::io::Error::other)?,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect CLI args, skipping the binary name (argv[0]).
|
// Collect CLI args, skipping the binary name (argv[0]).
|
||||||
@@ -495,7 +518,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
matrix_shutdown_tx: Some(Arc::clone(&matrix_shutdown_tx)),
|
matrix_shutdown_tx: Some(Arc::clone(&matrix_shutdown_tx)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_routes(ctx, whatsapp_ctx.clone(), slack_ctx.clone());
|
let app = build_routes(ctx, whatsapp_ctx.clone(), slack_ctx.clone(), port);
|
||||||
|
|
||||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
// messages if `.storkit/bot.toml` is present and enabled.
|
// messages if `.storkit/bot.toml` is present and enabled.
|
||||||
|
|||||||
+28
-3
@@ -123,7 +123,32 @@ pub async fn rebuild_and_restart(
|
|||||||
workspace_root.display()
|
workspace_root.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Build the server binary, matching the current build profile so the
|
// 3. Rebuild the frontend bundle so rust-embed picks up the latest assets.
|
||||||
|
let frontend_dir = workspace_root.join("frontend");
|
||||||
|
if frontend_dir.join("package.json").exists() {
|
||||||
|
slog!("[rebuild] Building frontend");
|
||||||
|
let fe_output = tokio::task::spawn_blocking({
|
||||||
|
let frontend_dir = frontend_dir.clone();
|
||||||
|
move || {
|
||||||
|
std::process::Command::new("npm")
|
||||||
|
.args(["run", "build"])
|
||||||
|
.current_dir(&frontend_dir)
|
||||||
|
.output()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Frontend build task panicked: {e}"))?
|
||||||
|
.map_err(|e| format!("Failed to run npm run build: {e}"))?;
|
||||||
|
|
||||||
|
if !fe_output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&fe_output.stderr);
|
||||||
|
slog!("[rebuild] Frontend build failed:\n{stderr}");
|
||||||
|
return Err(format!("Frontend build failed:\n{stderr}"));
|
||||||
|
}
|
||||||
|
slog!("[rebuild] Frontend build succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the server binary, matching the current build profile so the
|
||||||
// re-exec via current_exe() picks up the new binary.
|
// re-exec via current_exe() picks up the new binary.
|
||||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||||
vec!["build", "-p", "storkit"]
|
vec!["build", "-p", "storkit"]
|
||||||
@@ -152,14 +177,14 @@ pub async fn rebuild_and_restart(
|
|||||||
|
|
||||||
slog!("[rebuild] Build succeeded, re-execing with new binary");
|
slog!("[rebuild] Build succeeded, re-execing with new binary");
|
||||||
|
|
||||||
// 4. Send shutdown notification before replacing the process so that chat
|
// 5. Send shutdown notification before replacing the process so that chat
|
||||||
// participants know the bot is going offline. Best-effort only — we
|
// participants know the bot is going offline. Best-effort only — we
|
||||||
// do not abort the rebuild if the send fails.
|
// do not abort the rebuild if the send fails.
|
||||||
if let Some(n) = notifier {
|
if let Some(n) = notifier {
|
||||||
n.notify(ShutdownReason::Rebuild).await;
|
n.notify(ShutdownReason::Rebuild).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Re-exec with the new binary.
|
// 6. Re-exec with the new binary.
|
||||||
// Use the cargo output path rather than current_exe() so that rebuilds
|
// Use the cargo output path rather than current_exe() so that rebuilds
|
||||||
// inside Docker work correctly — the running binary may be installed at
|
// inside Docker work correctly — the running binary may be installed at
|
||||||
// /usr/local/bin/storkit (read-only) while cargo writes the new binary
|
// /usr/local/bin/storkit (read-only) while cargo writes the new binary
|
||||||
|
|||||||
Reference in New Issue
Block a user