初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# Delete Batch Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add explicit batch deletion support for `/delete 1,2,3`, `/delete 3-7`, and `/delete 1,3-5,8` without introducing ambiguous whitespace-based parsing.
**Architecture:** Keep the existing single-delete flow intact for one plain argument, and add a narrow parser that only activates when `/delete` receives one argument containing comma/range syntax. Resolve list positions from a single session snapshot, deduplicate targets, then execute deletions with a combined reply that reports successes and blocked items.
**Tech Stack:** Go 1.24, existing `core.Engine` command handlers, `testing` package.
---
### Task 1: Add failing command tests
**Files:**
- Modify: `core/engine_test.go`
**Step 1: Write the failing test**
Add command-level tests for:
- `/delete 1,2,3`
- `/delete 3-7`
- `/delete 1,3-5,8`
- invalid explicit syntax like `/delete 1,3-a,8`
- non-supported whitespace-separated args staying non-batch
**Step 2: Run test to verify it fails**
Run: `go test ./core -run TestCmdDelete`
Expected: FAIL because batch parsing does not exist yet.
### Task 2: Implement explicit batch delete parsing
**Files:**
- Modify: `core/engine.go`
- Modify: `core/i18n.go`
**Step 1: Write minimal implementation**
Add a helper that:
- only recognizes one-argument explicit batch syntax
- parses comma-separated integers and inclusive ranges
- rejects malformed items
- deduplicates indices while preserving order
Update `cmdDelete` to:
- route explicit batch syntax through the new helper
- keep existing single-delete behavior for plain one-argument input
- reject ambiguous multi-argument inputs with usage text
- aggregate batch results into one reply
**Step 2: Run targeted tests**
Run: `go test ./core -run TestCmdDelete`
Expected: PASS
### Task 3: Verify no regression in core tests
**Files:**
- Test: `core/engine_test.go`
- Test: `core/i18n_test.go`
**Step 1: Run broader verification**
Run: `go test ./core/...`
Expected: PASS
@@ -0,0 +1,66 @@
# Feishu Delete Card Design
**Date:** 2026-03-11
**Goal:** Let Feishu users enter `/delete` with no arguments to open a card-based multi-select delete flow, while keeping explicit delete arguments such as `/delete 1,2,3` working as direct command execution.
## Scope
- Only `/delete` with no arguments activates card selection mode.
- The normal `/list` card remains unchanged.
- Explicit delete arguments continue to bypass the card flow.
- Card flow is only for card-capable platforms; non-card platforms keep usage text.
## Interaction Flow
1. User sends `/delete`.
2. If the platform supports cards, cc-connect renders a delete-mode session list card.
3. Each session row exposes a single right-side button that toggles selected/unselected state.
4. The card footer provides:
- `删除已选`
- `取消`
- pagination controls
5. `删除已选` opens a confirmation card listing the selected sessions.
6. The confirmation card provides:
- `确认删除`
- `返回继续选择`
7. On confirmation, cc-connect deletes the selected sessions, skips the active session, clears the temporary selection state, and renders a result card.
## State Model
Delete-mode state should be tracked per `sessionKey`, separate from the agent interactive process state. The minimum state needed is:
- whether delete mode is active
- current page in delete mode
- selected session IDs
- whether the user is currently on the confirmation card
Session IDs, not row numbers, must be the source of truth after selection, so cross-page selection remains stable even if list ordering changes between renders.
## Card Actions
- `act:/delete-mode open`
- `act:/delete-mode toggle <session-id>`
- `act:/delete-mode page <n>`
- `act:/delete-mode confirm`
- `act:/delete-mode back`
- `act:/delete-mode submit`
- `act:/delete-mode cancel`
All delete-mode actions should update the card in place on Feishu. They should not dispatch as plain user commands.
## Error Handling
- Empty selection cannot proceed to confirmation; the card should stay in delete mode with a hint.
- Deleting the active session must be reported as blocked, not silently ignored.
- If a selected session no longer exists, report it in the result card rather than failing the whole batch.
- Cancel must always clear delete-mode state.
## Testing
- Rendering delete-mode cards with selected/unselected rows
- In-place toggle behavior and page persistence
- Confirmation card content
- Submit path deleting only selected session IDs
- Active-session protection in card-driven batch delete
- Explicit `/delete 1,3-5,8` continuing to work outside card mode
+175
View File
@@ -0,0 +1,175 @@
# Feishu Delete Card Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Feishu card-based multi-select delete flow triggered by `/delete` without changing existing explicit batch delete command semantics.
**Architecture:** Introduce a per-session delete-mode state keyed by `sessionKey`, render a dedicated delete-selection card and confirmation card, and route Feishu card `act:` callbacks through new delete-mode actions. Keep `/list` unchanged and keep explicit `/delete <args>` command execution on the existing code path.
**Tech Stack:** Go 1.24, `core.Engine`, shared card builder utilities, Feishu interactive cards, Go `testing`.
---
### Task 1: Add failing tests for delete-mode card flow
**Files:**
- Modify: `core/engine_test.go`
**Step 1: Write the failing test**
Add tests for:
- `/delete` with no args on a card-capable platform renders delete-mode card instead of usage text
- delete-mode list toggles selected session IDs through card actions
- confirmation card lists selected sessions
- submit deletes only selected sessions and clears delete-mode state
- cancel returns to normal list/current view and clears state
**Step 2: Run test to verify it fails**
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
Expected: FAIL because delete-mode state and card actions do not exist.
**Step 3: Write minimal implementation**
Add the smallest delete-mode state and rendering hooks required for the first test to pass before expanding behavior.
**Step 4: Run test to verify it passes**
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
Expected: PASS for the implemented slice.
**Step 5: Commit**
```bash
git add core/engine_test.go core/engine.go core/i18n.go
git commit -m "feat: add delete mode card flow scaffolding"
```
### Task 2: Implement delete-mode state and card rendering
**Files:**
- Modify: `core/engine.go`
- Modify: `core/i18n.go`
- Test: `core/engine_test.go`
**Step 1: Write the failing test**
Add tests covering:
- selected rows remain selected across pagination
- delete-mode footer buttons enable confirmation only when selection exists
- confirmation card back button preserves selection
**Step 2: Run test to verify it fails**
Run: `go test ./core -run 'TestDeleteMode'`
Expected: FAIL on missing state transitions or incorrect card rendering.
**Step 3: Write minimal implementation**
Implement:
- delete-mode state structure
- render function for delete-mode list
- render function for confirmation/result cards
- helper to resolve display names from selected session IDs
**Step 4: Run test to verify it passes**
Run: `go test ./core -run 'TestDeleteMode'`
Expected: PASS
**Step 5: Commit**
```bash
git add core/engine.go core/engine_test.go core/i18n.go
git commit -m "feat: render delete selection cards"
```
### Task 3: Wire Feishu card actions to delete-mode behavior
**Files:**
- Modify: `core/engine.go`
- Test: `core/engine_test.go`
- Inspect: `platform/feishu/feishu.go`
**Step 1: Write the failing test**
Add tests for:
- `act:/delete-mode toggle <id>`
- `act:/delete-mode confirm`
- `act:/delete-mode back`
- `act:/delete-mode submit`
- `act:/delete-mode cancel`
**Step 2: Run test to verify it fails**
Run: `go test ./core -run 'TestDeleteMode'`
Expected: FAIL because action dispatch does not recognize delete-mode actions.
**Step 3: Write minimal implementation**
Update `handleCardNav` and `executeCardAction` to:
- mutate delete-mode state in place
- re-render the correct card after each action
- clear state on submit/cancel
**Step 4: Run test to verify it passes**
Run: `go test ./core -run 'TestDeleteMode'`
Expected: PASS
**Step 5: Commit**
```bash
git add core/engine.go core/engine_test.go
git commit -m "feat: wire delete mode card actions"
```
### Task 4: Reuse deletion logic and protect edge cases
**Files:**
- Modify: `core/engine.go`
- Test: `core/engine_test.go`
**Step 1: Write the failing test**
Add tests for:
- active session in selected set is blocked and reported
- missing session ID in selected set is reported without aborting the whole batch
- explicit `/delete 1,2,3` and `/delete 1,3-5,8` still use command parsing path
**Step 2: Run test to verify it fails**
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
Expected: FAIL on batch execution/reporting edge cases.
**Step 3: Write minimal implementation**
Refactor deletion helpers so card-mode submit can delete by selected session ID while sharing reply/result formatting with the command path.
**Step 4: Run test to verify it passes**
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
Expected: PASS
**Step 5: Commit**
```bash
git add core/engine.go core/engine_test.go
git commit -m "feat: execute delete mode batch removal"
```
### Task 5: Run broader verification
**Files:**
- Test: `core/engine_test.go`
- Test: `platform/feishu/platform_test.go`
**Step 1: Run core verification**
Run: `go test ./core/...`
Expected: PASS
**Step 2: Run repository verification**
Run: `go test ./...`
Expected: PASS
@@ -0,0 +1,105 @@
# Multi-Workspace Feature Design
## Overview
Enable a single cc-connect bot (one Slack token) to serve multiple workspaces, with the channel determining which Claude Code working directory and session to use.
## Config
```toml
[[projects]]
name = "claude"
mode = "multi-workspace"
base_dir = "~/workspace"
[projects.agent]
type = "claudecode"
permission_mode = "yolo"
[[projects.platforms]]
type = "slack"
bot_token = "xoxb-..."
app_token = "xapp-..."
```
- `mode = "multi-workspace"` enables the feature. Omitting or `"single"` preserves current behavior.
- `base_dir` is the parent directory where workspaces live. Replaces `work_dir` on the agent.
- Agent config has no `work_dir` — resolved per-channel at runtime.
## Workspace Resolution Flow
When a message arrives in a channel:
1. **Check bindings** — look up `workspace_bindings.json` for an existing channel-to-workspace mapping.
2. **Convention match** — if no binding, check if `<base_dir>/<channel-name>/` exists. If yes, auto-bind and confirm:
> "Found `~/workspace/model-profiler` matching this channel. Binding workspace and starting session... Ready."
3. **Ask for repo** — if no match, reply:
> "No workspace found for this channel. What repo should I clone?"
User provides URL, bot confirms:
> "I'll clone `org/repo` to `~/workspace/repo-name` and bind to this channel. OK?"
4. **Clone and bind** — on confirmation, clone the repo, save the binding, spawn agent subprocess. Explicit feedback throughout:
> "Cloning `github.com/org/repo` to `~/workspace/repo-name`..."
> "Clone complete. Binding workspace to this channel... Ready."
### Binding Storage
Persisted in `~/.cc-connect/workspace_bindings.json`:
```json
{
"project:claude": {
"C0AKYKUF75K": {
"channel_name": "model-profiler",
"workspace": "/home/leigh/workspace/model-profiler",
"bound_at": "2026-03-12T10:00:00Z"
}
}
}
```
## Agent Subprocess Management
Engine maintains `workspaceAgents map[string]*workspaceState` keyed by workspace path. Each `workspaceState` holds the agent subprocess, its SessionManager, and a `lastActivity` timestamp.
### Lifecycle
1. **Spawn on first message** — start a Claude Code subprocess with `work_dir` set to the resolved workspace.
2. **Resume on subsequent messages** — reuse the running subprocess with saved session ID.
3. **Idle reap** — background goroutine checks `lastActivity` every minute. Subprocesses idle >15 minutes are stopped. Session ID is preserved so the next message transparently restarts.
4. **Graceful shutdown** — on bot shutdown, stop all subprocesses cleanly.
### Session Management
Each workspace gets its own SessionManager instance with a separate JSON file (same naming scheme as today: `project_hash.json`). Named sessions within a workspace work exactly as they do now.
## Message Routing Changes
In `Engine.handleMessage`, the multi-workspace path inserts before the existing flow:
1. **Extract channel ID** from the message's session key (`slack:channelID:userID`).
2. **Resolve workspace** — look up binding, convention match, or trigger init flow.
3. **If no workspace resolved** (init flow in progress) — handle the init conversation directly, don't forward to any agent.
4. **If workspace resolved** — get or spawn the agent subprocess for that workspace, then continue with existing message processing.
`interactiveStates` gets keyed by workspace+sessionKey (rather than just sessionKey) so the same user in different channels hits different agent processes.
### New Commands
- `/workspace` — show current channel's bound workspace
- `/workspace init <url>` — clone and bind
- `/workspace unbind` — remove binding
- `/workspace list` — show all bindings
Existing commands (`/sessions`, `/model`, etc.) work per-workspace.
## Error Handling & Edge Cases
- **Unbound channel, bot mentioned** — bot asks for repo URL. No agent forwarding until binding is established.
- **Clone fails** (bad URL, auth, disk) — bot reports error and asks user to try again. No partial binding saved.
- **Workspace directory deleted externally** — on next message, bot detects missing directory, removes the binding, re-enters init flow: "Workspace `~/workspace/foo` no longer exists. What repo should I clone?"
- **Agent subprocess crashes** — restart on next message using saved session ID (same as current behavior).
- **Bot in unwanted channel** — without binding or matching directory, it just asks for a repo. User can ignore or remove the bot.
## Architecture: Approach 1 (Engine-level multiplexing)
The Engine itself handles multi-workspace routing. No new meta-engine or wrapper layers. The multi-workspace logic is gated behind the `mode` config field, so single-workspace projects are completely unaffected.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-03-12-multi-workspace-plan.md",
"tasks": [
{"id": 1, "subject": "Task 1: Add config fields", "status": "pending"},
{"id": 2, "subject": "Task 2: Workspace binding persistence", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Workspace state and idle reaper", "status": "pending", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: Channel name resolution in Slack platform", "status": "pending"},
{"id": 5, "subject": "Task 5: Engine multi-workspace fields and constructor", "status": "pending", "blockedBy": [2, 3]},
{"id": 6, "subject": "Task 6: Workspace resolution logic", "status": "pending", "blockedBy": [4, 5]},
{"id": 7, "subject": "Task 7: Init flow conversation handler", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: Wire multi-workspace routing into handleMessage", "status": "pending", "blockedBy": [6, 7]},
{"id": 9, "subject": "Task 9: Workspace commands", "status": "pending", "blockedBy": [6]},
{"id": 10, "subject": "Task 10: Wire multi-workspace in main.go", "status": "pending", "blockedBy": [5]},
{"id": 11, "subject": "Task 11: Integration testing", "status": "pending", "blockedBy": [8, 9, 10]},
{"id": 12, "subject": "Task 12: Update config.example.toml and verify build", "status": "pending", "blockedBy": [11]}
],
"lastUpdated": "2026-03-12T01:30:00Z"
}
+139
View File
@@ -0,0 +1,139 @@
# Usage Command Design
**Date:** 2026-03-12
**Goal:** Add a built-in `/usage` command that reports model/account quota usage, starting with Codex running under ChatGPT OAuth, while keeping the retrieval path generic so other agents can plug in later.
## Scope
- Add a new built-in slash command: `/usage`.
- The command is independent from `/status` and `/doctor`.
- Usage retrieval is exposed as an optional agent capability, not hardcoded into the engine for a single vendor.
- First implementation targets the Codex agent when local ChatGPT OAuth credentials are available in `~/.codex/auth.json`.
- Unsupported agents should return a clear “not supported” style message rather than failing the whole command system.
## Architecture
### Command Layer
`core/engine.go` will register and dispatch `/usage` as a normal built-in command. The engine should not know how ChatGPT, Gemini, or any future provider exposes quota data. It only detects whether the current agent implements a usage-reporting interface and formats the response.
### Agent Capability Layer
Add a new optional interface in `core/interfaces.go`, for example:
- `UsageReporter`
- a method such as `GetUsage(context.Context) (*UsageReport, error)`
The report type should be generic enough to cover multiple providers:
- provider/agent name
- subject or account label
- plan type / tier if available
- one or more rate-limit buckets/windows
- optional credits/balance fields
- raw provider-specific metadata only if needed for debugging
This keeps future integrations local to each agent package.
### Codex Implementation
`agent/codex` will implement the new interface by:
1. Reading `~/.codex/auth.json`
2. Extracting:
- `tokens.access_token`
- `tokens.account_id`
3. Calling:
- `GET https://chatgpt.com/backend-api/wham/usage`
4. Passing headers:
- `Authorization: Bearer <access_token>`
- `ChatGPT-Account-Id: <account_id>`
- `User-Agent: codex-cli`
5. Mapping the JSON response into the generic usage report
If `auth.json` is missing, fields are absent, or the HTTP call fails, the agent should return a normal error so `/usage` can present a concise failure message.
## Data Model
The generic report should support at least:
- identity:
- provider
- account_id
- user/email if available
- plan:
- plan_type
- standard rate limits:
- allowed
- limit_reached
- primary window
- secondary window
- review/code-review limits:
- same window shape when available
- credits:
- has_credits
- unlimited
- balance
Each window should preserve:
- used percent
- total window seconds
- reset-after seconds
- reset timestamp if supplied
This is enough for the current ChatGPT OAuth response and still general enough for other providers with multiple quota windows.
## Output Format
The first output version should be plain text and compact. Suggested shape:
- title line with agent/provider
- plan line
- standard usage section
- code review usage section if present
- credits section if present
For each window:
- whether requests are currently allowed
- whether the limit is reached
- used percent
- reset timing
Prefer rendering both relative time and absolute time if easy to do consistently. If absolute rendering is added, it should use local time formatting already used by the project, or a simple RFC3339 fallback.
## Error Handling
- Agent does not implement usage reporting:
- reply with a user-facing “current agent does not support `/usage`
- Codex auth file missing:
- explain that ChatGPT OAuth login data was not found
- Token/account id missing:
- explain credentials are incomplete
- HTTP non-200:
- include status code in logs; show concise failure text to user
- JSON decode failure:
- report provider response parse failure
Do not expose bearer tokens or raw auth file contents in user-visible output or logs.
## Testing
Minimum tests should cover:
- command dispatch recognizes `/usage`
- engine returns unsupported message when agent lacks the interface
- engine formats a successful generic usage report
- Codex usage fetch maps a representative `wham/usage` payload correctly
- Codex usage fetch errors on missing auth file / missing token fields
Network-dependent tests should use injected transport or `httptest`, not live requests.
## Non-Goals
- No merging into `/status` or `/doctor`
- No card UI in the first version
- No polling or background caching in the first version
- No support for non-Codex agents in this change beyond the shared interface
+155
View File
@@ -0,0 +1,155 @@
# Usage Command Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a built-in `/usage` command with a generic agent usage-reporting interface, and implement the first provider-backed version for Codex using ChatGPT OAuth quota data.
**Architecture:** The engine gains a new built-in command that depends only on an optional `UsageReporter` interface. Agents that implement the interface return a generic `UsageReport`; the engine formats that into text. The Codex agent reads ChatGPT OAuth credentials from `~/.codex/auth.json`, calls the `wham/usage` endpoint, and maps the response into the generic structure.
**Tech Stack:** Go, built-in command dispatch in `core/engine.go`, optional agent interfaces in `core/interfaces.go`, HTTP/JSON via Go standard library, table-driven tests.
---
### Task 1: Add generic usage-reporting types
**Files:**
- Modify: `core/interfaces.go`
**Step 1: Add the failing interface usage surface**
Define:
- `UsageReporter`
- `UsageReport`
- `UsageBucket`
- `UsageWindow`
- `UsageCredits`
The structure should cover provider identity, plan/tier, generic allowed/limit-reached flags, one or more windows, and credits metadata.
**Step 2: Run targeted compile check**
Run: `go test ./core/...`
Expected: compile failures in engine/tests until the command layer is wired.
**Step 3: Keep the types minimal**
Do not add provider-specific fields unless they are broadly useful.
**Step 4: Re-run compile check**
Run: `go test ./core/...`
Expected: still failing only because `/usage` command is not yet fully integrated.
### Task 2: Add `/usage` built-in command and output formatter
**Files:**
- Modify: `core/engine.go`
- Modify: `core/i18n.go`
- Test: `core/engine_test.go`
**Step 1: Write/extend failing tests**
Add tests for:
- `/usage` dispatch to a supporting agent
- unsupported-agent message
- successful output formatting with representative report data
**Step 2: Run the targeted tests**
Run: `go test ./core/... -run 'Test.*Usage|TestHandleCommand'`
Expected: FAIL because the command is not registered yet.
**Step 3: Implement command plumbing**
Update:
- built-in command registration
- command dispatch switch
- bot command listing
- help/i18n text
Add a `cmdUsage` implementation that:
- type-asserts `UsageReporter`
- fetches usage with timeout
- formats a concise text response
**Step 4: Run the targeted tests again**
Run: `go test ./core/... -run 'Test.*Usage|TestHandleCommand'`
Expected: PASS
### Task 3: Implement Codex usage retrieval against ChatGPT OAuth
**Files:**
- Modify: `agent/codex/codex.go`
- Add or Modify: `agent/codex/usage.go`
- Test: `agent/codex/usage_test.go`
**Step 1: Write failing tests for the mapper/fetcher**
Cover:
- successful parsing of a representative `wham/usage` payload
- missing `auth.json`
- missing token/account fields
- HTTP error response
**Step 2: Run the targeted tests**
Run: `go test ./agent/codex -run 'Test.*Usage'`
Expected: FAIL because the implementation does not exist yet.
**Step 3: Implement minimal production code**
Add:
- auth file path resolver
- auth JSON loader
- HTTP request builder
- response DTOs
- mapping into `core.UsageReport`
Prefer dependency injection for:
- auth file path / reader
- HTTP client / transport
So tests avoid live network and real user files.
**Step 4: Run the targeted tests again**
Run: `go test ./agent/codex -run 'Test.*Usage'`
Expected: PASS
### Task 4: End-to-end verification and cleanup
**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`
- Modify: `CHANGELOG.md`
**Step 1: Document the new command**
Add `/usage` to command lists and a short explanation that support depends on the current agent.
**Step 2: Run focused test suites**
Run: `go test ./core/... ./agent/codex`
Expected: PASS
**Step 3: Run broader verification**
Run: `go test ./...`
Expected: PASS, or identify unrelated pre-existing failures clearly.
**Step 4: Commit**
Run:
```bash
git add core/interfaces.go core/engine.go core/i18n.go core/engine_test.go agent/codex/codex.go agent/codex/usage.go agent/codex/usage_test.go README.md README.zh-CN.md CHANGELOG.md docs/plans/2026-03-12-usage-design.md docs/plans/2026-03-12-usage.md
git commit -m "feat: add usage command for codex oauth"
```
@@ -0,0 +1,118 @@
# Session Resilience Design
**Date:** 2026-03-13
**Status:** Approved
**Branch:** feat/multi-workspace
## Problem
Multi-workspace mode introduces long-lived, concurrent Claude Code sessions that are reaped on idle and resumed on demand. Several failure modes cause silent context loss ("context rot"):
1. **CWD mismatch** — workspace paths that differ by trailing slash, symlink, or relative segment map to different Claude Code session directories, causing resume to silently start a fresh session
2. **Resume failure** — when a session's context is too large, `--resume` fails with "Prompt is too long" and the session becomes permanently broken until manual `!new`
3. **Invisible context degradation** — users have no signal that context is filling up until Claude starts forgetting things
4. **Silent failures** — session lifecycle events (spawn, resume, reap, failure) lack diagnostic logging
## Design
### 1. Path Normalization
**Helper:** `normalizeWorkspacePath(path string) string` in `workspace_state.go`
```
filepath.Clean(path) → filepath.EvalSymlinks(cleanedPath)
```
If `EvalSymlinks` fails (path doesn't exist yet), fall back to `filepath.Clean` only.
**Applied at two sites:**
- `workspacePool.GetOrCreate(workspace)` — normalize the key before map lookup/insert
- Workspace binding resolution — normalize before the workspace string enters the system
**Logging:** `slog.Debug("workspace path normalized", "original", path, "normalized", result)` when normalization changes the input.
### 2. Resume Failure → Fresh Session Fallback
**Location:** `getOrCreateInteractiveStateWith()` in engine.go
**Current behavior:** `StartSession` failure → state with nil `agentSession` → broken until `!new`.
**New behavior:**
1. If `StartSession` fails AND `session.AgentSessionID != ""` (resume attempt):
- Log failure with diagnostics: session ID, error message, cwd
- Clear `session.AgentSessionID` and save
- Retry `agent.StartSession(ctx, "")` for a fresh session
- Post platform notification: *"Session context was too large to resume — starting fresh. Project context is preserved in CLAUDE.md."*
2. If fresh retry also fails → fall through to existing nil-state behavior
3. If original call was already fresh (`AgentSessionID == ""`) → no retry, fall through as today
**Notification:** Send via `p.Send(ctx, replyCtx, msg)` — both are available on the `interactiveState` being constructed.
### 3. Context Consumption Indicator
**Dual-track approach with logging to compare accuracy over time.**
#### Track A: SDK token counts (accurate, cc-connect-owned)
- In `processInteractiveEvents`, parse `result` events for `input_tokens` usage
- Store cumulative `input_tokens` on the `interactiveState` (updated each turn)
- Compute percentage: `input_tokens / 200_000 * 100` (model context window)
- Append `[ctx: XX%]` to every message relayed to the platform
#### Track B: Claude self-report (approximate, for comparison)
- Add to system prompt via `--append-system-prompt`: instruction to append `[ctx: ~XX%]` to every response
- Parse the self-reported value from Claude's output before relaying
#### Logging
On every turn that has both values:
```
slog.Info("context_usage",
"sdk_pct", sdkPct,
"self_reported_pct", selfReportedPct,
"session_key", sessionKey,
"input_tokens", inputTokens)
```
Over time, compare drift to decide whether the system prompt instruction adds value.
#### Display
Appended to every visible message relayed to the platform:
```
Here's the refactored auth module...
[ctx: 62%]
```
If no token data available yet (first message), skip the indicator.
### 4. Diagnostic Logging
Structured `slog` logging at key lifecycle points:
| Event | Level | Fields |
|-------|-------|--------|
| Session spawn | Info | normalized cwd, session ID (or "new"), model |
| Session resume | Info | session ID, JSONL file path, file size |
| Resume failure | Error | session ID, error, stderr, cwd, JSONL file size |
| Fresh fallback | Warn | original session ID, new session ID, cwd |
| Idle reap | Info | session key, workspace path, idle duration, token count at reap |
| Context per-turn | Info | session key, input_tokens, sdk_pct, self_reported_pct |
| Path normalization | Debug | original path, normalized path (only when changed) |
**JSONL file size:** Resolve via `findProjectDir` + stat at resume time. Log even if file not found (indicates cwd mismatch).
## Non-Goals
- **Proactive compaction** — likely to cause more trouble than it's worth; the context indicator gives users agency to compact manually
- **Session summary → new session pattern** — more robust but significantly more implementation work; revisit if resume-with-fallback proves insufficient
- **Disk/memory monitoring** — out of scope; can be added as operational tooling later
## Implementation Order
1. Path normalization (prerequisite for everything else being reliable)
2. Diagnostic logging (needed to verify the other changes work)
3. Resume failure fallback (highest-value fix)
4. Context consumption indicator (most complex, benefits from logging already being in place)
@@ -0,0 +1,793 @@
# Session Resilience Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Eliminate silent session context loss in multi-workspace mode by normalizing paths, handling resume failures gracefully, surfacing context consumption to users, and adding diagnostic logging.
**Architecture:** Four independent changes layered bottom-up: path normalization (prevents mismatches), diagnostic logging (makes failures visible), resume fallback (auto-recovers from broken resumes), context indicator (gives users agency over compaction).
**Tech Stack:** Go, Claude Code CLI (stream-json protocol), slog structured logging
**Design doc:** `docs/plans/2026-03-13-session-resilience-design.md`
---
### Task 1: Add `normalizeWorkspacePath` helper
**Files:**
- Modify: `core/workspace_state.go`
- Create: `core/workspace_state_test.go` (add test cases)
**Step 1: Write the failing test**
Add to `core/workspace_state_test.go`:
```go
func TestNormalizeWorkspacePath(t *testing.T) {
// Create a real temp directory for symlink tests
tmp := t.TempDir()
realDir := filepath.Join(tmp, "real-project")
if err := os.Mkdir(realDir, 0o755); err != nil {
t.Fatal(err)
}
symlink := filepath.Join(tmp, "link-project")
if err := os.Symlink(realDir, symlink); err != nil {
t.Skip("symlinks not supported")
}
tests := []struct {
name string
input string
want string
}{
{"trailing slash", realDir + "/", realDir},
{"double slash", filepath.Join(tmp, "real-project") + "//", realDir},
{"dot segment", filepath.Join(tmp, ".", "real-project"), realDir},
{"dotdot segment", filepath.Join(tmp, "real-project", "subdir", ".."), realDir},
{"symlink resolved", symlink, realDir},
{"nonexistent uses Clean only", "/nonexistent/path/./foo/../bar", "/nonexistent/path/bar"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeWorkspacePath(tt.input)
if got != tt.want {
t.Errorf("normalizeWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./core/ -run TestNormalizeWorkspacePath -v`
Expected: FAIL — `normalizeWorkspacePath` undefined
**Step 3: Write minimal implementation**
Add to `core/workspace_state.go`:
```go
import (
"log/slog"
"os"
"path/filepath"
)
// normalizeWorkspacePath cleans and resolves a workspace path to prevent
// mismatches caused by trailing slashes, symlinks, or relative segments.
// If the path cannot be resolved (e.g. doesn't exist yet), falls back to
// filepath.Clean only.
func normalizeWorkspacePath(path string) string {
cleaned := filepath.Clean(path)
resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
// Path doesn't exist yet — best effort
return cleaned
}
if resolved != path {
slog.Debug("workspace path normalized", "original", path, "normalized", resolved)
}
return resolved
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./core/ -run TestNormalizeWorkspacePath -v`
Expected: PASS
**Step 5: Commit**
```bash
git add core/workspace_state.go core/workspace_state_test.go
git commit -m "feat: add normalizeWorkspacePath helper for consistent pool keys"
```
---
### Task 2: Apply path normalization at entry points
**Files:**
- Modify: `core/workspace_state.go``GetOrCreate`
- Modify: `core/engine.go:5656,5681``resolveWorkspace` return values
- Modify: `core/engine.go:993``getOrCreateWorkspaceAgent`
**Step 1: Write failing tests**
Add to `core/workspace_state_test.go`:
```go
func TestWorkspacePoolNormalizesKeys(t *testing.T) {
tmp := t.TempDir()
realDir := filepath.Join(tmp, "project")
if err := os.Mkdir(realDir, 0o755); err != nil {
t.Fatal(err)
}
pool := newWorkspacePool(15 * time.Minute)
// Access with trailing slash
ws1 := pool.GetOrCreate(realDir + "/")
// Access without trailing slash
ws2 := pool.GetOrCreate(realDir)
if ws1 != ws2 {
t.Error("trailing slash created a different workspace state")
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./core/ -run TestWorkspacePoolNormalizesKeys -v`
Expected: FAIL — two different states returned
**Step 3: Normalize in `GetOrCreate` and `Get`**
In `core/workspace_state.go`, modify `GetOrCreate`:
```go
func (p *workspacePool) GetOrCreate(workspace string) *workspaceState {
workspace = normalizeWorkspacePath(workspace)
p.mu.Lock()
defer p.mu.Unlock()
if s, ok := p.states[workspace]; ok {
return s
}
s := newWorkspaceState(workspace)
p.states[workspace] = s
return s
}
```
Modify `Get` similarly:
```go
func (p *workspacePool) Get(workspace string) *workspaceState {
workspace = normalizeWorkspacePath(workspace)
p.mu.RLock()
defer p.mu.RUnlock()
return p.states[workspace]
}
```
**Step 4: Normalize in `resolveWorkspace` returns**
In `core/engine.go`, at lines 5656 and 5681 where workspace paths are returned, wrap with normalization:
At line 5656:
```go
return normalizeWorkspacePath(b.Workspace), b.ChannelName, nil
```
At line 5681:
```go
normalized := normalizeWorkspacePath(candidate)
e.workspaceBindings.Bind(projectKey, channelID, channelName, normalized)
slog.Info("workspace auto-bound by convention",
"channel", channelName, "workspace", normalized)
return normalized, channelName, nil
```
**Step 5: Run tests**
Run: `go test ./core/ -run TestWorkspacePool -v`
Expected: PASS
**Step 6: Commit**
```bash
git add core/workspace_state.go core/workspace_state_test.go core/engine.go
git commit -m "feat: normalize workspace paths at pool and resolution entry points"
```
---
### Task 3: Add token usage fields to Event and parse in session
**Files:**
- Modify: `core/message.go:87-98` — add `InputTokens`, `OutputTokens` to `Event`
- Modify: `agent/claudecode/session.go:268-282` — parse usage from result JSON
**Step 1: Write failing test**
Add to a new file or existing test for claudecode session parsing. Since `handleResult` is on the unexported `claudeSession`, test via the event channel:
Create `agent/claudecode/session_test.go` test (or add to existing):
```go
func TestHandleResultParsesUsage(t *testing.T) {
// Simulate a result event JSON with usage data
raw := map[string]any{
"type": "result",
"result": "test response",
"session_id": "sess-123",
"usage": map[string]any{
"input_tokens": float64(50000),
"output_tokens": float64(1500),
},
}
cs := &claudeSession{
events: make(chan core.Event, 1),
ctx: context.Background(),
done: make(chan struct{}),
}
cs.alive.Store(true)
cs.handleResult(raw)
evt := <-cs.events
if evt.InputTokens != 50000 {
t.Errorf("InputTokens = %d, want 50000", evt.InputTokens)
}
if evt.OutputTokens != 1500 {
t.Errorf("OutputTokens = %d, want 1500", evt.OutputTokens)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./agent/claudecode/ -run TestHandleResultParsesUsage -v`
Expected: FAIL — `InputTokens` field doesn't exist on Event
**Step 3: Add fields to Event**
In `core/message.go`, add to the `Event` struct:
```go
InputTokens int // populated for EventResult — total input tokens this turn
OutputTokens int // populated for EventResult — output tokens this turn
```
**Step 4: Parse usage in handleResult**
In `agent/claudecode/session.go`, modify `handleResult`:
```go
func (cs *claudeSession) handleResult(raw map[string]any) {
var content string
if result, ok := raw["result"].(string); ok {
content = result
}
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.sessionID.Store(sid)
}
var inputTokens, outputTokens int
if usage, ok := raw["usage"].(map[string]any); ok {
if v, ok := usage["input_tokens"].(float64); ok {
inputTokens = int(v)
}
if v, ok := usage["output_tokens"].(float64); ok {
outputTokens = int(v)
}
}
evt := core.Event{
Type: core.EventResult,
Content: content,
SessionID: cs.CurrentSessionID(),
Done: true,
InputTokens: inputTokens,
OutputTokens: outputTokens,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
```
**Step 5: Run test to verify it passes**
Run: `go test ./agent/claudecode/ -run TestHandleResultParsesUsage -v`
Expected: PASS
**Step 6: Commit**
```bash
git add core/message.go agent/claudecode/session.go agent/claudecode/session_test.go
git commit -m "feat: parse token usage from Claude Code result events"
```
---
### Task 4: Track context percentage on interactiveState and append to messages
**Files:**
- Modify: `core/engine.go:192-202` — add `inputTokens` field to `interactiveState`
- Modify: `core/engine.go:1326-1352` — track tokens on EventResult
- Modify: `core/engine.go:1354+` — append `[ctx: XX%]` to relayed messages
**Step 1: Add field to interactiveState**
In `core/engine.go`, add to the `interactiveState` struct:
```go
type interactiveState struct {
// ... existing fields ...
inputTokens int // last known input_tokens from result event (context size proxy)
}
```
**Step 2: Update EventResult handler to track tokens and append indicator**
In `processInteractiveEvents`, in the `case EventResult:` block (around line 1326), after the existing session ID handling:
```go
case EventResult:
if event.SessionID != "" {
session.mu.Lock()
session.AgentSessionID = event.SessionID
session.mu.Unlock()
}
// Track context consumption
if event.InputTokens > 0 {
state.mu.Lock()
state.inputTokens = event.InputTokens
state.mu.Unlock()
}
fullResponse := event.Content
if fullResponse == "" && len(textParts) > 0 {
fullResponse = strings.Join(textParts, "")
}
if fullResponse == "" {
fullResponse = e.i18n.T(MsgEmptyResponse)
}
// Append context indicator
if event.InputTokens > 0 {
pct := event.InputTokens * 100 / 200_000
fullResponse += fmt.Sprintf("\n[ctx: %d%%]", pct)
}
// ... rest of existing EventResult handling
```
**Step 3: Also append indicator to intermediate messages (tool use, thinking)**
For every visible message sent during a turn, we need the indicator. The simplest approach: store the last known percentage on the state, and have a helper:
```go
func contextIndicator(inputTokens int) string {
if inputTokens <= 0 {
return ""
}
pct := inputTokens * 100 / 200_000
return fmt.Sprintf("\n[ctx: %d%%]", pct)
}
```
Append `contextIndicator(state.inputTokens)` to every `e.send()` call in the event loop for EventThinking, EventToolUse, and EventResult. Read `state.inputTokens` under the state mutex that's already being acquired.
**Step 4: Run existing tests**
Run: `go test ./core/ -v -count=1`
Expected: PASS (no test changes needed — this is additive to message content)
**Step 5: Commit**
```bash
git add core/engine.go
git commit -m "feat: append context consumption indicator [ctx: XX%] to relayed messages"
```
---
### Task 5: Add system prompt instruction for Claude self-reporting
**Files:**
- Modify: `core/interfaces.go:36-81` — append context self-report instruction to `AgentSystemPrompt()`
**Step 1: Add instruction to system prompt**
At the end of the `AgentSystemPrompt()` return string, before the closing backtick, add:
```go
## Context awareness
At the end of every message you send, append your estimate of your context window consumption as: [ctx: ~XX%]
This helps the user decide when to run /compact. Be honest if you're unsure, estimate conservatively.
```
**Step 2: Run existing tests**
Run: `go test ./core/ -v -count=1`
Expected: PASS
**Step 3: Commit**
```bash
git add core/interfaces.go
git commit -m "feat: instruct agent to self-report context usage for comparison logging"
```
---
### Task 6: Add dual-track context logging
**Files:**
- Modify: `core/engine.go` — EventResult handler, add structured log with both values
**Step 1: Parse self-reported percentage from response**
Add helper in `core/engine.go`:
```go
import "regexp"
var ctxSelfReportRe = regexp.MustCompile(`\[ctx:\s*~?(\d+)%\]`)
// parseSelfReportedCtx extracts the self-reported context percentage from a response.
// Returns -1 if not found.
func parseSelfReportedCtx(response string) int {
m := ctxSelfReportRe.FindStringSubmatch(response)
if m == nil {
return -1
}
v, _ := strconv.Atoi(m[1])
return v
}
```
**Step 2: Write test for parser**
```go
func TestParseSelfReportedCtx(t *testing.T) {
tests := []struct {
input string
want int
}{
{"some response\n[ctx: ~45%]", 45},
{"response [ctx: 80%]", 80},
{"no indicator", -1},
{"[ctx: ~100%] mid-text", 100},
}
for _, tt := range tests {
got := parseSelfReportedCtx(tt.input)
if got != tt.want {
t.Errorf("parseSelfReportedCtx(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
```
**Step 3: Run test to verify it fails, implement, verify pass**
Run: `go test ./core/ -run TestParseSelfReportedCtx -v`
**Step 4: Add structured logging in EventResult handler**
After computing the SDK percentage but before appending the indicator, add:
```go
if event.InputTokens > 0 {
sdkPct := event.InputTokens * 100 / 200_000
selfPct := parseSelfReportedCtx(fullResponse)
slog.Info("context_usage",
"session_key", sessionKey,
"sdk_pct", sdkPct,
"self_reported_pct", selfPct,
"input_tokens", event.InputTokens,
"output_tokens", event.OutputTokens,
)
}
```
**Step 5: Strip Claude's self-reported indicator before appending the real one**
So we don't show duplicate indicators, strip the self-reported one from the response before appending the SDK-based one:
```go
// Strip self-reported indicator (we replace it with the accurate SDK one)
fullResponse = ctxSelfReportRe.ReplaceAllString(fullResponse, "")
fullResponse = strings.TrimRight(fullResponse, "\n ")
fullResponse += fmt.Sprintf("\n[ctx: %d%%]", sdkPct)
```
**Step 6: Commit**
```bash
git add core/engine.go core/engine_test.go
git commit -m "feat: dual-track context usage logging (SDK vs self-reported)"
```
---
### Task 7: Add diagnostic logging to session lifecycle
**Files:**
- Modify: `core/engine.go:1097-1119` — spawn/resume logging
- Modify: `core/engine.go:259-283` — reap logging
- Modify: `agent/claudecode/session.go:42-117` — spawn logging with JSONL path/size
- Modify: `agent/claudecode/claudecode.go:195-224` — log cwd at StartSession
**Step 1: Enhanced spawn logging in `getOrCreateInteractiveStateWith`**
Replace the existing log at line 1118 with:
```go
slog.Info("session spawned",
"session_key", sessionKey,
"agent_session", session.AgentSessionID,
"is_resume", session.AgentSessionID != "",
"elapsed", startElapsed,
)
```
**Step 2: Add JSONL file size logging in `claudecode.StartSession`**
In `agent/claudecode/claudecode.go`, in `StartSession`, before calling `newClaudeSession`, add:
```go
if sessionID != "" {
// Log session file details for diagnostics
homeDir, _ := os.UserHomeDir()
absWorkDir, _ := filepath.Abs(a.workDir)
if homeDir != "" {
projectDir := findProjectDir(homeDir, absWorkDir)
sessionFile := filepath.Join(projectDir, sessionID+".jsonl")
if info, err := os.Stat(sessionFile); err == nil {
slog.Info("session resume attempt",
"session_id", sessionID,
"jsonl_path", sessionFile,
"jsonl_size_bytes", info.Size(),
"work_dir", absWorkDir,
)
} else {
slog.Warn("session file not found for resume",
"session_id", sessionID,
"expected_path", sessionFile,
"work_dir", absWorkDir,
"error", err,
)
}
}
}
```
**Step 3: Enhanced reap logging in `runIdleReaper`**
In `core/engine.go`, in the reap loop (around line 271), add idle duration:
```go
reaped := e.workspacePool.ReapIdle()
for _, ws := range reaped {
e.interactiveMu.Lock()
for key, state := range e.interactiveStates {
if state.workspaceDir == ws {
state.mu.Lock()
tokenCount := state.inputTokens
state.mu.Unlock()
slog.Info("session idle-reaped",
"session_key", key,
"workspace", ws,
"last_ctx_pct", tokenCount*100/200_000,
"input_tokens", tokenCount,
)
if state.agentSession != nil {
state.agentSession.Close()
}
delete(e.interactiveStates, key)
}
}
```
**Step 4: Log stderr on session process failure**
In `agent/claudecode/session.go`, the `readLoop` already logs stderr on failure (line 125). Enhance it:
```go
slog.Error("claudeSession: process failed",
"error", err,
"stderr", stderrMsg,
"work_dir", cs.workDir,
"session_id", cs.CurrentSessionID(),
)
```
**Step 5: Run all tests**
Run: `go test ./core/ ./agent/claudecode/ -v -count=1`
Expected: PASS
**Step 6: Commit**
```bash
git add core/engine.go agent/claudecode/session.go agent/claudecode/claudecode.go
git commit -m "feat: add diagnostic logging for session spawn, resume, reap, and failure"
```
---
### Task 8: Resume failure fallback with user notification
**Files:**
- Modify: `core/engine.go:1097-1105` — retry logic in `getOrCreateInteractiveStateWith`
**Step 1: Write failing test**
Add to `core/engine_test.go`:
```go
func TestResumeFailureFallsBackToFreshSession(t *testing.T) {
callCount := 0
agent := &stubAgent{
startSessionFunc: func(ctx context.Context, sessionID string) (core.AgentSession, error) {
callCount++
if sessionID != "" {
// Simulate resume failure
return nil, fmt.Errorf("Prompt is too long")
}
// Fresh session succeeds
return &stubAgentSession{alive: true}, nil
},
}
e := newTestEngine(t)
e.agent = agent
session := e.sessions.GetOrCreateActive("test:chan:user")
session.AgentSessionID = "old-session-id"
p := &stubPlatform{}
state := e.getOrCreateInteractiveState("test:chan:user", p, nil, session)
if state.agentSession == nil {
t.Fatal("expected agentSession to be non-nil after fallback")
}
if callCount != 2 {
t.Errorf("expected 2 StartSession calls (resume + fresh), got %d", callCount)
}
if session.AgentSessionID != "" {
t.Errorf("expected AgentSessionID cleared, got %q", session.AgentSessionID)
}
}
```
Note: This test may need adjustment to match the actual test helpers in the codebase. Check `core/engine_test.go` for the existing `stubAgent` and `newTestEngine` patterns and adapt accordingly.
**Step 2: Run test to verify it fails**
Run: `go test ./core/ -run TestResumeFailureFallsBackToFreshSession -v`
Expected: FAIL — current code doesn't retry
**Step 3: Implement retry logic**
Replace the error handling block in `getOrCreateInteractiveStateWith` (lines 1100-1104):
```go
startAt := time.Now()
agentSession, err := agent.StartSession(e.ctx, session.AgentSessionID)
startElapsed := time.Since(startAt)
if err != nil {
if session.AgentSessionID != "" {
// Resume failed — log diagnostics and retry with fresh session
slog.Error("session resume failed, falling back to fresh session",
"session_key", sessionKey,
"failed_session_id", session.AgentSessionID,
"error", err,
"elapsed", startElapsed,
)
// Clear the stale session ID
session.mu.Lock()
session.AgentSessionID = ""
session.mu.Unlock()
// Notify user
if p != nil {
go func() {
_ = p.Send(context.Background(), replyCtx,
"⚠️ Session context was too large to resume — starting fresh. Project context is preserved in CLAUDE.md.")
}()
}
// Retry with fresh session
freshStart := time.Now()
agentSession, err = agent.StartSession(e.ctx, "")
freshElapsed := time.Since(freshStart)
if err != nil {
slog.Error("fresh session also failed",
"session_key", sessionKey,
"error", err,
"elapsed", freshElapsed,
)
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: quietMode}
e.interactiveStates[sessionKey] = state
return state
}
slog.Info("fresh session started after resume failure",
"session_key", sessionKey,
"elapsed", freshElapsed,
)
} else {
slog.Error("failed to start interactive session",
"session_key", sessionKey,
"error", err,
"elapsed", startElapsed,
)
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: quietMode}
e.interactiveStates[sessionKey] = state
return state
}
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./core/ -run TestResumeFailureFallsBackToFreshSession -v`
Expected: PASS
**Step 5: Run full test suite**
Run: `go test ./core/ ./agent/claudecode/ -v -count=1`
Expected: PASS
**Step 6: Commit**
```bash
git add core/engine.go core/engine_test.go
git commit -m "feat: auto-recover from resume failure with fresh session and user notification"
```
---
### Task 9: Final integration verification
**Files:** None — verification only
**Step 1: Run full test suite**
Run: `go test ./... -count=1`
Expected: PASS
**Step 2: Verify build**
Run: `go build ./...`
Expected: no errors
**Step 3: Review all changes**
Run: `git log --oneline main..HEAD`
Verify the commit sequence matches the plan:
1. `normalizeWorkspacePath` helper
2. Apply normalization at entry points
3. Token usage fields + parsing
4. Context indicator on messages
5. System prompt self-report instruction
6. Dual-track logging
7. Diagnostic lifecycle logging
8. Resume failure fallback
**Step 4: Final commit (if any fixups needed)**
```bash
git add -A && git commit -m "fix: address issues found during integration verification"
```
@@ -0,0 +1,15 @@
{
"planPath": "docs/plans/2026-03-13-session-resilience-plan.md",
"tasks": [
{"id": 7, "subject": "Task 1: Add normalizeWorkspacePath helper", "status": "pending"},
{"id": 8, "subject": "Task 2: Apply path normalization at entry points", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 3: Add token usage fields to Event and parse in session", "status": "pending"},
{"id": 10, "subject": "Task 4: Track context % on interactiveState and append to messages", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 5: Add system prompt instruction for self-reporting", "status": "pending"},
{"id": 12, "subject": "Task 6: Add dual-track context logging", "status": "pending", "blockedBy": [10, 11]},
{"id": 13, "subject": "Task 7: Add diagnostic logging to session lifecycle", "status": "pending"},
{"id": 14, "subject": "Task 8: Resume failure fallback with user notification", "status": "pending"},
{"id": 15, "subject": "Task 9: Final integration verification", "status": "pending", "blockedBy": [8, 12, 13, 14]}
],
"lastUpdated": "2026-03-13T00:00:00Z"
}
@@ -0,0 +1,83 @@
# ACP 适配层设计(草案)
本文描述在 cc-connect 中增加 **Agent Client ProtocolACP** 适配的可行方案,目标是让 **已实现 ACP Agent 端** 的上游进程(见 [官方 Agents 列表](https://agentclientprotocol.com/get-started/agents))能通过 **统一协议**接入现有 `core.Engine`,减少为每个 CLI 单独维护解析逻辑的成本。
## 1. 背景与术语
- **ACP**:基于 JSON-RPC 的标准,用于 **Client(如编辑器)↔ Agent(编码助手进程)** 通信;与 IM 无关。
- **对 cc-connect 的价值**:在 `agent/` 侧实现 **ACP Client**(连接子进程或 socket 上的 ACP Agent),将 ACP 消息映射为现有的 `core.Agent` / `core.AgentSession` / `core.Event`,从而使 **飞书 / Telegram 等平台** 与「任意兼容 ACP 的 Agent 后端」对接。
- **不在本文范围(可选二期)**:让 cc-connect **作为 ACP Agent 对外暴露**,供 Zed 等编辑器直连;需完整实现协议 Agent 侧,工作量更大。
## 2. 架构约束(与仓库规则一致)
- `core/` **不** import `agent/*`;新逻辑全部放在 `agent/acp/`(或 `agent/acpclient/`)。
- 通过 `core.RegisterAgent("acp", factory)``init()` 注册;`cmd/cc-connect/plugin_agent_acp.go` + `Makefile` + `config.example.toml` 与现有 agent 插件一致。
- 权限、会话、卡片等多为 Engine 已有能力;适配层专注 **协议 ↔ Event**
## 3. 目标与非目标
### 3.1 一期目标(MVP
- 配置驱动启动子进程:`command` + `args` + `work_dir` + `env`(与现有 agent options 风格一致)。
- Transport**stdio JSON-RPC**ACP 文档中最常见);后续再评估 HTTP/WebSocket。
- 映射能力(按优先级):
1. 会话生命周期:与 `StartSession` / `Close` / `CurrentSessionID` 对齐。
2. **Prompt turn**:用户文本(及后续可选图片/文件)→ ACP 对应方法;响应流 → `Event``EventResult``EventThinking`、增量文本等,与现有 Engine 消费方式一致)。
3. **工具调用与用户批准**:映射到 `EventPermission` + `RespondPermission`(若 ACP 方法名与字段与 core 不完全一致,在适配层做字段转换)。
- 单项目、单用户会话语义与现有一致:`session_key` 仍由 Platform 提供,ACP 侧使用独立 `sessionID` 字符串与 cc-connect 会话绑定策略需在实现阶段定稿(建议:cc-connect `sessionID` 传入 adapterACP session id 由子进程返回或持久化路径配置)。
### 3.2 明确延后(二期+
- ACP **File System / Terminal** 全量映射(若与 IM 展示模型差距大,可先降级为文本摘要或仅日志)。
- **Slash commands / Agent plan** 与 IM 命令体系的统一(可先忽略或透传为纯文本)。
- cc-connect **作为 ACP Server** 供编辑器连接。
## 4. 组件划分
| 组件 | 职责 |
|------|------|
| `agent/acp/agent.go` | 实现 `core.Agent``Name``StartSession``ListSessions``Stop` |
| `agent/acp/session.go` | 实现 `core.AgentSession``Send``Events``RespondPermission``Close` 等 |
| `agent/acp/rpc.go`(或 `transport_stdio.go` | stdio 上的 JSON-RPC 读写、request id、并发与取消 |
| `agent/acp/mapping.go` | ACP 通知/结果 → `core.Event``PermissionResult` ↔ ACP 工具批准结构 |
| 测试 | 子进程 mock:固定 JSON-RPC 回放 fixture,避免 CI 依赖真实 Cursor/Codex 二进制 |
## 5. 配置草案(`config.example.toml`
```toml
# [[projects]]
# [projects.agent]
# type = "acp"
# [projects.agent.options]
# command = "path/to/agent" # 或 npx / uvx 等
# args = [] # 可选
# # cwd 默认 work_direnv 可扩展
# # acp_transport = "stdio" # 默认;预留 "http" 等
```
具体字段名以实现时与 `config` 解析为准,需 **向后兼容**:未安装插件时 `no_acp` build tag 行为与现有 agent 一致。
## 6. 风险与依赖
- **协议版本**:需锁定所实现的 ACP schema 版本;上游变更时通过集成测试与 changelog 跟进。
- **Agent 差异**:列表中各产品对 ACP 子集支持不同;MVP 文档中写明「已验证」矩阵(至少 1~2 个开源/可脚本化 Agent)。
- **router / 代理场景**:若子进程同时向 stdout 打非 JSON 日志,会破坏流式解析;与 claudecode `router_url` 下禁用 `--verbose` 同类问题需在 ACP 层统一约束(仅 JSON-RPC 行写入协议通道)。
## 7. 实施顺序建议
1. 阅读官方 **Protocol / Session / Prompt / Content / Tool** 章节与 **Schema**,列出与 `core.Event` 的字段对照表。
2. 实现 stdio transport + 最小会话握手(无 UI)。
3. 打通一轮 prompt → 文本结果 → `EventResult`
4. 接入权限与工具事件;补 `engine` 层无需改动的验证测试。
5. 文档:`docs/` 简短用户说明 + `config.example.toml` 示例。
6. (可选)在 CI 中使用 mock server 跑 `go test ./agent/acp/...`
## 8. 参考链接
- [ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
- [ACP Agents 列表](https://agentclientprotocol.com/get-started/agents)
- [Protocol Overview](https://agentclientprotocol.com/protocol/overview)(以官网当前版本为准)
---
*Status: design draft — 实现跟踪可在本文件追加「Implementation log」小节或单独 tasks JSON。*
+111
View File
@@ -0,0 +1,111 @@
# Integration Test Plan
Integration tests verify real agent-platform interactions using actual agent binaries
with a mock platform. Tests are gated by `//go:build integration` and excluded from
normal CI. Run with:
```bash
go test -tags=integration ./tests/integration/...
```
## Philosophy
- **Real agents, mocked platform**: Agents run as real subprocesses; platform is mocked
to record and verify all messages without network dependencies.
- **Agent pooling**: Agent instances are reused across tests to avoid per-test startup
overhead (Claude Code cold start ~3-6s).
- **Resilient assertions**: Use case-insensitive substring matching, generous timeouts,
and skip agents that fail due to auth/infra issues (e.g., OpenCode needs GitLab token).
---
## Implemented Cases
### Session Management
- [x] `TestNewSession_ClaudeCode` — New session spawns, agent responds
- [x] `TestNewSession_Codex` — Same for Codex
- [x] `TestListSessions_ShowsActiveSessions``/list` shows active sessions
- [x] `TestSwitchSession``/switch` changes active session
- [x] `TestStopCommand``/stop` interrupts active session
- [x] `TestNewSessionClearsContext` — After `/new`, prior context is cleared
- [x] `TestHistoryCommand``/history` returns conversation history
- [x] `TestConcurrentSessionIsolation` — Two sessions don't cross-talk
### Agent Interaction
- [x] `TestEventParsing_ThinkToolUse` — Tool calls (echo) are parsed and produce output
- [x] `TestMarkdownLongTextChunking` — Long responses chunked correctly
- [x] `TestPermissionModeSwitch``/mode yolo` and `/mode default` work
- [x] `TestAgentCodex` — Codex agent responds
- [x] `TestAgentCursor` — Cursor agent responds (⚠️ may respond in locale)
- [x] `TestAgentGemini` — ⚠️ Fails due to quota exhaustion in CI env
- [x] `TestAgentOpencode` — ⚠️ Requires GitLab auth, skipped
- [x] `TestSharedCasesAcrossAgents` — Same prompts validated across agents
- [x] `TestLongTextChunking` — Very long user input (5k+ chars) processed
### Commands & Built-ins
- [x] `TestShellCommand``/shell` executes (skips if `admin_from` not set)
- [x] `TestProviderSwitch``/provider list` works
### i18n
- [x] `TestLanguageSwitch``/lang zh` changes language (⚠️ may skip due to locale)
- [x] `TestEmptyMessage` — Whitespace-only messages handled gracefully
### Message Handling
- [x] `TestImageAttachmentRouting` — Image-bearing messages routed (⚠️ needs real image)
---
## Planned Cases (not yet implemented)
### Multi-Agent / Provider
- [ ] `TestSessionResume` — Send `/new`, reconnect to same session key, verify context preserved
- [ ] `TestProviderSwitchActual``/provider switch <name>` actually changes provider mid-session
- [ ] `TestModelSwitch``/model <name>` changes model; responses reflect new model
- [ ] `TestConcurrentMultiAgent` — Two different agent types active simultaneously
### Commands & Built-ins
- [ ] `TestCustomCommand` — Register and invoke a custom command
- [ ] `TestAliasCommand` — Create/use alias; verify substitution
- [ ] `TestDirCommand``/dir` navigates workspace; agent respects new directory
- [ ] `TestSearchCommand``/search <query>` invokes search
### Permission & Safety
- [ ] `TestPermissionPromptBypass``yolo` mode bypasses permission prompts; `default` shows them
- [ ] `TestSensitiveMessageRedaction` — Tokens/secrets in user input are redacted
- [ ] `TestBannedWordBlocking` — Banned-word messages rejected with feedback
### Message Handling
- [ ] `TestFileAttachmentRouting` — File attachments reach agent
- [ ] `TestVoiceMessageHandling` — Voice messages transcribed/processed or gracefully rejected
- [ ] `TestMarkdownParsing` — Markdown in agent responses rendered correctly
### Rate Limiting & Performance
- [ ] `TestIncomingRateLimit` — Rapid messages rate-limited; excess queued/rejected
- [ ] `TestOutgoingRateLimit` — Rapid agent output respects platform limits
- [ ] `TestSlowAgentTimeout` — Slow agent (>idle timeout) flagged or session reaped
### i18n
- [ ] `TestMultiLanguageResponses` — Same prompt in different language configs produces localized responses
### Error & Edge Cases
- [ ] `TestBadAgentOutput` — Malformed agent output handled gracefully (no panic)
- [ ] `TestAgentCrashRecovery` — Agent dies mid-session; engine detects, notifies, allows respawn
- [ ] `TestVeryLongAgentResponse` — Extremely long response (>50k chars); chunking works, no OOM
- [ ] `TestConcurrentSessionCreation` — Rapid session creation; keys unique, no state leakage
### ACP / Relay
- [ ] `TestACPMessageRelay` — ACP message relayed to agent; response returns via correct channel
- [ ] `TestRelaySessionKeyPreservation``CC_SESSION_KEY` propagated through relay; session continuity maintained
---
## Notes
- **Timeout guidelines**: Simple prompts ("say hi") — 30s; tool use — 60s; slow agents
(gemini, opencode) — 90s; long output — 120s.
- **Skip vs Fail**: Auth/infra failures (`Skip`) are expected in some environments;
code bugs should `Fatal`. Use `t.Skipf("reason")` for expected env issues.
- **Agent pool reuse**: The pool key includes `workDir`, so tests using the same workDir
share the same agent instance. Use `t.TempDir()` for isolation.
- **Parallel tests**: Use `t.Parallel()` for independent tests. Avoid parallel subtests
that share session keys to prevent race conditions.