fix(agent-claude-code): fold underscores in Claude project slug (#1611) (#1612)

`toClaudeProjectPath` only normalized `/`, `.`, and `:` — but Claude Code's
on-disk slug also folds underscores (and other non-alphanumerics) to `-`.
AO project data dirs are named `<sanitized>_<hash>` (e.g.
`graph-isomorphism_d185b44d56`), so the slug AO computed pointed at a
directory that never existed. Cascading failures:

- `getSessionInfo` couldn't read the JSONL → `claudeSessionUuid` never
  got persisted to session metadata.
- On restore, `getRestoreCommand`'s metadata lookup found nothing AND its
  workspace-scan fallback also missed (same bad slug), returning `null`.
- Session-manager's native-restore guard then threw
  `SessionNotRestorableError` → API returned 409.

Verified on-disk: every session under projects without underscores has
`claudeSessionUuid` persisted; every session under projects with
underscores does not. The orchestrator angle in #1611 is the loudest
symptom — orchestrators die early so they have nothing else to fall back
on — but the same bug silently broke worker restore in any multi-project
setup.

Fix: replace `[/.]` with `[^a-zA-Z0-9-]` in the slug regex, matching
Claude Code's actual encoding. Adds direct unit tests for
`toClaudeProjectPath` covering the underscore case plus existing paths,
and a regression test in the `getSessionInfo` path-conversion suite.

Fixes #1611.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-05-03 20:40:18 +05:30 committed by GitHub
parent 3b9ba3122e
commit e465a4702d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 62 additions and 9 deletions

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-plugin-agent-claude-code": patch
---
Fix `toClaudeProjectPath` to fold underscores (and any other non-alphanumeric character) to dashes, matching Claude Code's actual on-disk slug encoding. Previously only `/`, `.`, and `:` were normalized, so AO project data dirs of the form `<sanitized>_<hash>` produced slugs that pointed to non-existent directories — `getSessionInfo` and `getRestoreCommand` could never locate the session JSONL, `claudeSessionUuid` never got persisted, and restoring orchestrator/worker sessions in any multi-project setup failed with a 409 "getRestoreCommand returned null". Fixes #1611.

View File

@ -61,6 +61,7 @@ import {
manifest,
default as defaultExport,
resetPsCache,
toClaudeProjectPath,
METADATA_UPDATER_SCRIPT,
} from "./index.js";
@ -149,6 +150,40 @@ beforeEach(() => {
mockHomedir.mockReturnValue("/mock/home");
});
describe("toClaudeProjectPath", () => {
it("encodes a plain unix path", () => {
expect(toClaudeProjectPath("/Users/dev/projects/foo")).toBe("-Users-dev-projects-foo");
});
it("collapses dot directories like .worktrees into a leading double dash", () => {
expect(toClaudeProjectPath("/Users/dev/.worktrees/ao/ao-3")).toBe(
"-Users-dev--worktrees-ao-ao-3",
);
});
it("normalizes underscores to dashes (issue #1611)", () => {
// AO project data dirs are named `<sanitized>_<hash>`. Claude Code converts
// underscores to dashes when computing its on-disk project slug; without
// matching that here the slug points to a non-existent directory and
// restore loses the conversation.
expect(
toClaudeProjectPath(
"/Users/dev/.agent-orchestrator/projects/graph-isomorphism_d185b44d56/worktrees/gi-orchestrator",
),
).toBe(
"-Users-dev--agent-orchestrator-projects-graph-isomorphism-d185b44d56-worktrees-gi-orchestrator",
);
});
it("strips Windows drive colons and folds backslashes", () => {
expect(toClaudeProjectPath("C:\\Users\\dev\\foo")).toBe("C-Users-dev-foo");
});
it("collapses any other non-alphanumeric character into a dash", () => {
expect(toClaudeProjectPath("/Users/dev/proj@v2/foo bar")).toBe("-Users-dev-proj-v2-foo-bar");
});
});
describe("plugin manifest & exports", () => {
it("has correct manifest", () => {
expect(manifest).toEqual({
@ -505,6 +540,19 @@ describe("getSessionInfo", () => {
"/mock/home/.claude/projects/-Users-dev--worktrees-ao-ao-3",
);
});
it("normalizes underscores to dashes (matches Claude Code on-disk slug, issue #1611)", async () => {
mockJsonlFiles('{"type":"user","message":{"content":"hello"}}');
await agent.getSessionInfo(
makeSession({
workspacePath:
"/Users/dev/.agent-orchestrator/projects/graph-isomorphism_d185b44d56/worktrees/gi-orchestrator",
}),
);
expect(mockReaddir).toHaveBeenCalledWith(
"/mock/home/.claude/projects/-Users-dev--agent-orchestrator-projects-graph-isomorphism-d185b44d56-worktrees-gi-orchestrator",
);
});
});
describe("summary extraction", () => {

View File

@ -231,21 +231,21 @@ export const manifest = {
* Convert a workspace path to Claude's project directory path.
* Claude stores sessions at ~/.claude/projects/{encoded-path}/
*
* Verified against Claude Code's actual encoding (as of v1.x):
* the path has its leading / stripped, then all / and . are replaced with -.
* e.g. /Users/dev/.worktrees/ao Users-dev--worktrees-ao
* Verified against Claude Code's actual on-disk slugs: every non-alphanumeric
* character (other than `-`) is replaced with `-`. That includes `/`, `.`,
* and crucially `_` AO's per-project data dirs are named like
* `<sanitized>_<hash>`, and without underscore folding the slug AO computes
* misses the directory Claude actually wrote (issue #1611).
*
* If Claude Code changes its encoding scheme this will silently break
* introspection. The path can be validated at runtime by checking whether
* the resulting directory exists.
* Windows drive letters keep their special handling: `C:\Users\...` strip
* the colon, then encode `C-Users-...`.
*
* Exported for testing purposes.
*/
export function toClaudeProjectPath(workspacePath: string): string {
// Handle Windows drive letters (C:\Users\... → C-Users-...)
const normalized = workspacePath.replace(/\\/g, "/");
// Claude Code replaces / and . with - (keeping the leading slash as a leading -)
return normalized.replace(/:/g, "").replace(/[/.]/g, "-");
const normalized = workspacePath.replace(/\\/g, "/").replace(/:/g, "");
return normalized.replace(/[^a-zA-Z0-9-]/g, "-");
}
/** Find the most recently modified .jsonl session file in a directory */