Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
2bf1163
chore: git-ignore .superpowers/ SDD scratch (progress ledger, task br…
itsdestin Aug 12, 2026
ee4123f
test(slugs): CC-generated slug fixtures from the 2.1.229 probe (spec §8)
itsdestin Aug 12, 2026
29bbc20
feat(slugs): split ccProjectSlug (true CC mirror, cap+hash) from froz…
itsdestin Aug 12, 2026
9daa8cc
test(slugs): pin the over-cap suffix to an independently computed lit…
itsdestin Aug 12, 2026
9261113
feat(android): CC project-slug mirror anchored to the 2.1.229 probe f…
itsdestin Aug 12, 2026
be3fcc4
refactor(slugs): route the native store + permissions lane to frozen …
itsdestin Aug 12, 2026
a75dfa2
fix(android): re-key SyncService to the CC slug mirror everywhere; de…
itsdestin Aug 12, 2026
836ee2f
docs(slugs): restore the raw-slug wording the brief mandated (review …
itsdestin Aug 12, 2026
d29ee1a
Merge branch 'fix/project-slug-encoding-android': Kotlin CC mirror + …
itsdestin Aug 12, 2026
5148feb
refactor(slugs): claude lane on the real CC mirror; cwdToProjectSlug …
itsdestin Aug 12, 2026
109c244
fix(slugs): realpath the caller path in projectConversationHistory to…
itsdestin Aug 12, 2026
238e950
feat(watcher): watch CC's own transcript_path from the hook; slug der…
itsdestin Aug 12, 2026
7a405a2
test(watcher): pin subagentsDir to the hook path's own dirname (revie…
itsdestin Aug 12, 2026
8495b76
fix(chat): pass CC's transcript_path + cwd from the hook payload; per…
itsdestin Aug 12, 2026
0d9fc31
feat(slugs): R1/R2 transcript-cwd rules with the mid-file-switch fixt…
itsdestin Aug 12, 2026
df861d0
fix(slugs): inject platform through the R1/R2 rules so tests hold on …
itsdestin Aug 12, 2026
d26795e
fix(resume): exact slug inversion — recorded cwd, then forward re-slu…
itsdestin Aug 12, 2026
45c26e1
test(conversations): pin record-wins bucketing in the reconcile sweep…
itsdestin Aug 12, 2026
27cf140
feat(repair): transcript classification core — identical/subset/super…
itsdestin Aug 12, 2026
9004cc5
fix(repair): same-uuid content divergence classifies as fork, not sub…
itsdestin Aug 12, 2026
dcd9605
feat(repair): quarantine primitive — move/snapshot with decision log,…
itsdestin Aug 12, 2026
b51c285
feat(repair): §6.1 $HOME-fork repair — R2-owned, top-level only, case…
itsdestin Aug 12, 2026
a10bccb
fix(repair): live-guard the correct-dir copy; fail-closed promotion r…
itsdestin Aug 12, 2026
e85b494
feat(repair): §6.2 per-session record + space-bucket repair; never re…
itsdestin Aug 12, 2026
90267a2
fix(repair): fork-gate the space keeper; record-repaired finding kind…
itsdestin Aug 12, 2026
daa03d9
feat(repair): §6.3 orphan retirement + ordered idempotent startup run…
itsdestin Aug 13, 2026
c3f6c19
fix(repair): bring §6.3 orphan-dir repair to parity with §6.1's live-…
itsdestin Aug 13, 2026
485d29a
fix(repair): count a session's live-deferral once per run, not per fi…
itsdestin Aug 13, 2026
1a3f25b
fix(repair): scope §6.2 to R2-owned sessions; converge to zero findin…
itsdestin Aug 13, 2026
72c9e83
perf(resume): resolve each slug directory once, not per file (final r…
itsdestin Aug 13, 2026
ec86817
docs(slugs): cc-dependencies entries + review comment folds (final re…
itsdestin Aug 13, 2026
e9df416
fix(repair): known folders = managed projects + saved folders, matchi…
itsdestin Aug 16, 2026
ba9815b
fix(repair): quiesce the reconcile + materialize sweeps while the slu…
itsdestin Aug 16, 2026
6be7925
fix(repair): hold surfaced forks out of both mirror directions until …
itsdestin Aug 16, 2026
019cafa
fix(repair): only log/count a record repair when a field actually cha…
itsdestin Aug 16, 2026
7f2face
fix(repair): persist the fork hold before any store write; release on…
itsdestin Aug 16, 2026
fce5efb
chore(repair): sweep-pause depth counter, singleton reset, hermetic s…
itsdestin Aug 16, 2026
a077026
fix(repair): isolate repair stages so a late throw can't drop earlier…
itsdestin Aug 16, 2026
040beaf
Merge remote-tracking branch 'origin/master' into fix/project-slug-en…
itsdestin Aug 16, 2026
98d7d14
fix(permissions): route master's new revokeRule/revokeProject slug ma…
itsdestin Aug 16, 2026
ed8999c
test: canonicalize tmp roots + poll instead of sleeping so the slug t…
itsdestin Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ terminal-emulator-vendored/build/
# Built React UI bundle (copied from desktop build) — except placeholder
app/src/main/assets/web/*
!app/src/main/assets/web/index.html
.superpowers/
16 changes: 0 additions & 16 deletions app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import java.util.concurrent.ConcurrentHashMap
* to read only new content, and deduplicate by uuid.
*/
class TranscriptWatcher(
private val projectsDir: File, // e.g., $HOME/.claude/projects/
private val scope: CoroutineScope,
) {
companion object {
Expand All @@ -49,21 +48,6 @@ class TranscriptWatcher(
result = ANSI_REGEX.replace(result, "")
return result.trim()
}

/**
* Convert a working directory path to Claude Code's project slug.
* Mirrors desktop's cwdToProjectSlug(): replace \, :, /, and space with -.
* Leading dash is preserved. Space handling is required — CC encodes spaces
* as dashes too, and without it the watcher reads from a non-existent
* directory for any cwd containing spaces (e.g. "PAF 540 Final Data Project").
*/
fun cwdToProjectSlug(cwdPath: String): String {
return cwdPath
.replace('\\', '-')
.replace(':', '-')
.replace('/', '-')
.replace(' ', '-')
}
}

private val _events = MutableSharedFlow<TranscriptEvent>(extraBufferCapacity = 1000)
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/kotlin/com/youcoded/app/runtime/CcProjectSlug.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.youcoded.app.runtime

/**
* Mirrors Claude Code 2.1.229's ~/.claude/projects/<slug>/ encoding,
* bug-for-bug: every non-alphanumeric → '-'; slugs over 200 chars truncate
* and append base36(abs(rolling hash of the ORIGINAL path)).
* Anchored to CC-generated fixtures (CcProjectSlugTest) — never to the
* desktop TS implementation. Spec:
* youcoded-dev/docs/active/specs/2026-08-11-project-slug-encoding-repair.md §5.3.
*/
object CcProjectSlug {
// WHY caveat (final review, MINOR fold): Kotlin's Regex.replace iterates
// per Unicode CODE POINT; JS's String.replace(/[^a-zA-Z0-9]/g, ...) (the
// mirror this class mirrors) iterates per UTF-16 CODE UNIT. For any
// non-BMP character (e.g. emoji) that's two different answers — JS emits
// TWO replacement dashes (one per surrogate half), Kotlin emits ONE. This
// is currently UNREACHABLE: the only input this object ever receives is
// Android's canonicalHome, which is ASCII. Do NOT "fix" this to force
// agreement without a real non-ASCII cwd forcing the question — see
// slug-encoding.ts's own version note for the sibling mirror's anchor.
private const val MAX = 200
private val NON_ALNUM = Regex("[^a-zA-Z0-9]")

fun hash(s: String): String {
var h = 0
for (c in s) h = (h shl 5) - h + c.code // Int wraparound == JS's |0
// JS Math.abs on an int32 is exact (doubles); Kotlin abs(Int.MIN_VALUE)
// stays NEGATIVE. Widen to Long BEFORE abs or the mirror breaks at
// exactly int32-min. (Desktop's slug-encoding.ts documents the JS side.)
return kotlin.math.abs(h.toLong()).toString(36)
}

fun slug(cwd: String): String {
val s = NON_ALNUM.replace(cwd, "-")
return if (s.length <= MAX) s else s.substring(0, MAX) + "-" + hash(cwd)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ class SessionRegistry {
model = model,
)

val projectsDir = File(bootstrap.homeDir, ".claude/projects")
val transcriptWatcher = TranscriptWatcher(projectsDir, scope)
val transcriptWatcher = TranscriptWatcher(scope)

val session = ManagedSession(
id = sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,14 @@ class SessionService : Service() {
fun destroySession(sessionId: String) {
// Push this session's JSONL to all backends before destroying
// (mirrors desktop main.ts session-exit → syncService.pushSession)
// Capture the hook-supplied transcript path BEFORE sessionRegistry.destroySession()
// tears down the PTY bridge/EventBridge below — same design as desktop's
// watcher: no derivation can be wrong about a path CC handed us.
val transcriptPath = sessionRegistry.sessions.value[sessionId]?.ptyBridge?.getEventBridge()?.getTranscriptPath(sessionId)
syncService?.let { sync ->
serviceScope.launch {
try {
sync.pushSession(sessionId)
sync.pushSession(sessionId, transcriptPath)
} catch (e: Exception) {
android.util.Log.w("SessionService", "Session-end sync failed for $sessionId: $e")
}
Expand Down
53 changes: 30 additions & 23 deletions app/src/main/kotlin/com/youcoded/app/runtime/SyncService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -220,26 +220,26 @@ class SyncService(
}

// =========================================================================
// Slug Generation (CRITICAL — must match Claude Code's algorithm)
// Slug generation — ONE rule: CC's real encoding, mirrored by CcProjectSlug
// (fixture-anchored; desktop mirror: slug-encoding.ts). The old 4-char
// replace rule was deleted 2026-08-12: it named directories CC never
// writes (the home path contains dots), which silently killed session-end
// push, /resume aggregation, cross-device symlinking, AND the
// conversation-index stamp it fed (spec §5.3 — the index slug is resolved
// as a real path on restore, so it must be the real CC slug too).
// =========================================================================

/**
* Generate the current device's project slug.
* Must match TranscriptWatcher.cwdToProjectSlug() and desktop's getCurrentSlug().
* Replace /, \, :, and space with - to match Claude Code's slug algorithm.
*/
fun getCurrentSlug(): String {
// Use canonical path to resolve symlinks (e.g., /data/user/0 → /data/data)
val homePath = try {
bootstrap.homeDir.canonicalPath
} catch (_: Exception) {
bootstrap.homeDir.absolutePath
}
// Replace path separators + spaces with dashes — must match desktop exactly.
// Space handling is required: CC encodes spaces as dashes too.
return homePath.replace('/', '-').replace('\\', '-').replace(':', '-').replace(' ', '-')
private fun canonicalHome(): String = try {
// Canonical path resolves symlinks (/data/user/0 → /data/data) — the
// same realpath step CC itself applies before slugging.
bootstrap.homeDir.canonicalPath
} catch (_: Exception) {
bootstrap.homeDir.absolutePath
}

/** CC's real directory name for the home project. */
fun ccHomeSlug(): String = CcProjectSlug.slug(canonicalHome())

// =========================================================================
// Device Name (for conversation-index.json)
// =========================================================================
Expand Down Expand Up @@ -1273,7 +1273,7 @@ class SyncService(
} ?: JSONObject().apply { put("version", 1); put("sessions", JSONObject()) }

val sessions = index.getJSONObject("sessions")
val slug = getCurrentSlug()
val slug = ccHomeSlug()
val device = getDeviceName()
val now = System.currentTimeMillis()
val pruneThreshold = now - INDEX_PRUNE_DAYS * 24L * 60 * 60 * 1000
Expand Down Expand Up @@ -1381,7 +1381,7 @@ class SyncService(
val projectsDir = File(claudeDir, "projects")
if (!projectsDir.isDirectory) return

val currentSlug = getCurrentSlug()
val currentSlug = ccHomeSlug()

projectsDir.listFiles()?.forEach { slugDir ->
if (slugDir.name == currentSlug) return@forEach
Expand Down Expand Up @@ -1411,7 +1411,7 @@ class SyncService(
val projectsDir = File(claudeDir, "projects")
if (!projectsDir.isDirectory) return

val currentSlug = getCurrentSlug()
val currentSlug = ccHomeSlug()
val homeDir = File(projectsDir, currentSlug)
if (!homeDir.isDirectory) return

Expand Down Expand Up @@ -1519,11 +1519,18 @@ class SyncService(
// Session-End Push
// =========================================================================

/** Push a single session's JSONL to all backends (called on session close). */
suspend fun pushSession(sessionId: String) {
val slug = getCurrentSlug()
val jsonlFile = File(claudeDir, "projects/$slug/$sessionId.jsonl")
/** Push a single session's JSONL to all backends (called on session close).
* Prefers the hook-supplied transcript path (EventBridge) — same design as
* desktop's watcher: no derivation can be wrong about a path CC handed us. */
suspend fun pushSession(sessionId: String, transcriptPath: String? = null) {
val fromHook = transcriptPath?.let { java.io.File(it) }?.takeIf { it.isFile }
val jsonlFile = fromHook ?: java.io.File(claudeDir, "projects/${ccHomeSlug()}/$sessionId.jsonl")
if (!jsonlFile.exists()) return
// Remote bucket = the transcript's REAL containing dir name (CC wrote
// it), keeping session-end push consistent with the bulk push, which
// mirrors projects/ dirs verbatim. Fallback covers a hook path outside
// projects/ (should not happen; belt and suspenders).
val slug = jsonlFile.parentFile?.name ?: ccHomeSlug()

// Update conversation index first
updateConversationIndex()
Expand Down

This file was deleted.

65 changes: 65 additions & 0 deletions app/src/test/kotlin/com/youcoded/app/runtime/CcProjectSlugTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.youcoded.app.runtime

import org.junit.Assert.assertEquals
import org.junit.Test

/** Pairs copied from desktop/tests/fixtures/cc-slug-pairs.json (CC 2.1.229
* probe, 2026-08-12) — anchored to directories a REAL CC created, never to
* the desktop TS implementation. Regenerate both together (spec §7). */
class CcProjectSlugTest {
@Test fun `fixture pairs`() {
// probe: _ and .
assertEquals(
"-home-destin-YouCoded-probe-under-score-and-dots",
CcProjectSlug.slug("/home/destin/YouCoded/probe/under_score.and.dots"),
)
// probe: punctuation
assertEquals(
"-home-destin-YouCoded-probe-punct--x-----y---z",
CcProjectSlug.slug("/home/destin/YouCoded/probe/punct (x) + 'y' #z"),
)
// probe: over-cap
assertEquals(
"-home-destin-YouCoded-probe-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-6bal0v",
CcProjectSlug.slug(
"/home/destin/YouCoded/probe/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/" +
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/" +
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
),
)
// probe: symlink resolves to realpath
assertEquals(
"-home-destin-YouCoded-probe-real-target",
CcProjectSlug.slug("/home/destin/YouCoded/probe/real-target"),
)
// harvest: the reporting folder (comma+ampersand)
assertEquals(
"-home-destin-YouCoded-Projects-PAF-574---Diversity--Ethics----Public-Change",
CcProjectSlug.slug("/home/destin/YouCoded/Projects/PAF 574 - Diversity, Ethics, & Public Change"),
)
// harvest: plain
assertEquals("-home-destin", CcProjectSlug.slug("/home/destin"))
// harvest: hyphens are fixed points
assertEquals("-home-destin-youcoded-dev", CcProjectSlug.slug("/home/destin/youcoded-dev"))
// windows drive+backslash (synthetic, both rules agree)
assertEquals("C--Users-alice", CcProjectSlug.slug("C:\\Users\\alice"))
}

@Test fun `android home path gets the dashed slug CC writes`() {
assertEquals(
"-data-data-com-youcoded-app-files-home",
CcProjectSlug.slug("/data/data/com.youcoded.app/files/home"),
)
}

@Test fun `hash matches the JS reference`() {
assertEquals("22ci", CcProjectSlug.hash("abc"))
assertEquals("0", CcProjectSlug.hash(""))
}

@Test fun `int32-min hash does NOT go negative (the Kotlin-only trap)`() {
// JS Math.abs(-2147483648) = 2147483648; Kotlin abs(Int.MIN_VALUE) is
// NEGATIVE. The impl must widen to Long before abs. Pinned indirectly:
assertEquals("zik0zk", kotlin.math.abs(Int.MIN_VALUE.toLong()).toString(36))
}
}
5 changes: 2 additions & 3 deletions desktop/src/main/artifacts/projects-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { canonicalize } from '../../shared/artifacts/canonicalize';
import { readFolders, type SavedFolder } from '../saved-folders';
import { getManagedRoots } from '../sync-spaces/service';
import { listPastSessions } from '../session-browser';
import { cwdToProjectSlug } from '../transcript-watcher';
import { ccProjectSlug } from '../slug-encoding';

const CLAUDE_DIR = path.join(os.homedir(), '.claude');

Expand Down Expand Up @@ -131,8 +131,7 @@ export async function listProjectsIndex(opts?: { withCounts?: boolean }): Promis
// Conversation counts: a single global session scan, bucketed by CC slug.
// Only when requested — listPastSessions is heavier (global), and ChatView's
// frequent cwd-resolution calls don't need it.
const ccSlug = (projectPath: string) =>
cwdToProjectSlug(projectPath.replace(/^([a-z]):/, (_m, d) => `${d.toUpperCase()}:`));
const ccSlug = ccProjectSlug;
let convBySlug: Map<string, number> | null = null;
if (opts?.withCounts) {
const sessions = await listPastSessions();
Expand Down
10 changes: 5 additions & 5 deletions desktop/src/main/chatsearch-index/index-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import {
} from '../conversations/service';
import { getTagRegistry } from '../conversations/tag-registry-service';
import { NativeHome } from '../native-home';
import { cwdToProjectSlug } from '../transcript-watcher';
import { ccProjectSlug } from '../project-conversations';
import { nativeStoreSlug, ccProjectSlug } from '../slug-encoding';
import { laneMatches } from '../conversations/lane-guards';
import { buildMetaFile } from './meta-builder';
import {
Expand Down Expand Up @@ -238,11 +237,12 @@ async function refreshFromLiveState(): Promise<void> {
provider: 'native',
lane: 'native',
records: nativeRecords,
// RAW slug, not ccProjectSlug — the two encodings diverge deliberately
// (ccProjectSlug uppercases a lowercase Windows drive letter).
// RAW frozen app-private slug, not ccProjectSlug — the two encodings
// diverge deliberately (ccProjectSlug uppercases a lowercase Windows
// drive letter).
resolveTranscriptPath: (r) => resolveTranscriptPathTwoStep(
r,
path.join(home.root, 'sessions', cwdToProjectSlug(r.originalPath), `${r.id}.jsonl`),
path.join(home.root, 'sessions', nativeStoreSlug(r.originalPath), `${r.id}.jsonl`),
storeRoot,
),
},
Expand Down
13 changes: 10 additions & 3 deletions desktop/src/main/conversations/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { readSessionTranscriptMeta } from '../session-browser';
import { ccProjectSlug } from '../project-conversations';
import { ccProjectSlug } from '../slug-encoding';
import { transcriptSkipReason, MIN_TRANSCRIPT_BYTES } from './lane-guards';
import type { ConversationStore } from './conversation-store';
import type { ConversationRecord } from './store-core';
Expand Down Expand Up @@ -45,7 +45,14 @@ export interface ReconcileOpts {
function buildSlugToName(knownFolders: string[] | undefined): Map<string, string> {
const m = new Map<string, string>();
for (const folder of knownFolders ?? []) {
try { m.set(ccProjectSlug(folder).toLowerCase(), path.basename(folder)); }
try {
// CC slugs realpath(cwd) (see slug-encoding.ts fixture "symlink resolves to
// realpath"). Resolve the same way, falling back exactly as CC's Px() does,
// so a symlinked project folder finds CC's real directory.
let resolved: string;
try { resolved = fs.realpathSync.native(folder); } catch { resolved = folder; }
m.set(ccProjectSlug(resolved).toLowerCase(), path.basename(folder));
}
catch { /* unslugifiable path — skip */ }
}
return m;
Expand All @@ -64,7 +71,7 @@ function resolveProjectName(
// LAST-RESORT fallback used only when the folder is NOT among this device's
// known folders (see buildSlugToName/resolveProjectName, which recover the exact
// basename first). A CC slug is the cwd with separators flattened to '-'
// (cwdToProjectSlug); the original path is not recoverable from the slug alone,
// (ccProjectSlug); the original path is not recoverable from the slug alone,
// so this takes the LAST slug segment — a TRUNCATION for hyphenated names
// ('...-youcoded-dev' → 'dev'). Acceptable as a fallback because it's internally
// consistent (transcriptRef and the mirror key use the same string) and the live
Expand Down
Loading