Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/sync-upstream/LEDGER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,7 +110,7 @@ Self-cleaning rules (apply during every sync's ledger update):
- **`ChatMarkdown` resolves its environment with no active-environment fallback** (2026-08-26). The fork's split-view fix was `threadRef?.environmentId ?? activeEnvironmentId`; #7140 replaced it with `threadRef?.environmentId ?? explicitEnvironmentId ?? null` plus an explicit `environmentId` prop, and upstream's own review guidance now forbids a shared renderer falling back to the active environment. The fork's line was dropped by user decision because upstream's is a strict superset: every in-pane call site passes `threadRef`, and the thread-less surfaces (pull request panels) pass explicit scope. `null` there means "no environment", which correctly disables the file chip's open/reveal actions instead of aiming them at another machine. The fork's thread-scoped `claimWorkspaceBasenameLookup(key)` is separate and stays. Revisit only if upstream reintroduces an active-environment fallback.

- **`unsettledAt` and `movedToTopAt` are separate anchors, composed by max** (2026-08-26). Upstream's #8231 `unsettledAt` is automatic (set on `thread.unsettled`, cleared on settle) and the fork's `movedToTopAt` is an explicit user bump; different triggers, same ordering axis, both worth keeping. The fork's composed sorters take `Math.max(base, unsettledAt, movedToTopAt)` where `base` is the latest-user-message-or-creation chain. **Never compose with upstream's `activeThreadAnchorTimestampMs` there** — it folds `createdAt` in unconditionally, which floors the base chain and makes an imported thread (fresh `createdAt`, old messages, see `SessionImportService`) sort as brand new. Both clients carry a regression test named "does not floor the latest-user-message key with creation time". Upstream's own `sortThreadsForSidebar` fast path may keep using the helper: there `base` already is `createdAt`. Revisit if upstream gives its anchor a manual-bump concept of its own.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs *before* the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.
- **The Older shelf must count every anchor the active sorter honours** (2026-08-26). `threadIsOlder` runs _before_ the active comparator, so an anchor the shelf does not know about is moot: the row is filed away before the sort can lift it. #8231 exposed this — `unsettledAt` had to be added to `ThreadOlderSource` and `threadOlderRecencyAtMs` as integration work, in a file upstream never touches and no upstream test covers (upstream has no Older section). Any future recency anchor needs the same treatment.

## Watchpoints

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,9 +24,11 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe(
path.join(resolved, ".claude"),
);
expect(yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" })).toBe(
process.env,
);
// A snapshot, never `process.env` by reference: a live reference
// would observe the fork driver's temporary CLAUDE_CONFIG_DIR swap.
const environment = yield* makeClaudeEnvironment({ homePath: "", shadowHomePath: "" });
expect(environment).not.toBe(process.env);
expect(environment).toEqual({ ...process.env });
}),
);

Expand Down
22 changes: 11 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,11 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
config: Pick<ClaudeSettings, "homePath" | "shadowHomePath">,
baseEnv?: NodeJS.ProcessEnv,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
const resolvedBaseEnv = baseEnv ?? process.env;
// Always a copy, never the base env by reference: when the base is
// `process.env`, a by-reference environment would observe the fork driver's
// temporary CLAUDE_CONFIG_DIR override (see ClaudeSessionFork.ts) at
// whatever moment a session start happens to snapshot it.
const environment = { ...(baseEnv ?? process.env) };
// Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME.
// Overriding HOME also relocates the macOS login keychain lookup
// ($HOME/Library/Keychains), so the spawned CLI can't find its stored
Expand All@@ -73,18 +77,14 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function
// The shadow dir wins over homePath: the CLI must read this account's
// credentials, while shared state reaches the homePath dir through the
// materialized symlinks.
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: shadowConfigDirPath,
};
environment.CLAUDE_CONFIG_DIR = shadowConfigDirPath;
return environment;
}
const homePath = config.homePath.trim();
if (homePath.length === 0) return resolvedBaseEnv;
const resolvedHomePath = yield* resolveClaudeHomePath(config);
return {
...resolvedBaseEnv,
CLAUDE_CONFIG_DIR: resolvedHomePath,
};
if (homePath.length > 0) {
environment.CLAUDE_CONFIG_DIR = yield* resolveClaudeHomePath(config);
}
return environment;
});

// The continuation key deliberately ignores `shadowHomePath`: a shadow
Expand Down
160 changes: 113 additions & 47 deletions apps/server/src/provider/Drivers/ClaudeSessionFork.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,54 +3,120 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import { forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

it.layer(NodeServices.layer)("ClaudeSessionFork", (it) => {
it.effect("forks a real SDK transcript inside the configured Claude HOME", () =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
(homePath) =>
Effect.gen(function* () {
const sourceSessionId = "11111111-1111-4111-8111-111111111111";
const projectDirectory = NodePath.join(
homePath,
".claude",
"projects",
"fixture-project",
);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${sourceSessionId}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${sourceSessionId}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${sourceSessionId}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),

import { ClaudeSessionForkError, forkClaudePersistedSession } from "./ClaudeSessionFork.ts";

const SOURCE_SESSION_ID = "11111111-1111-4111-8111-111111111111";

const withTempConfigDir = <A, E>(
use: (configDirPath: string) => Effect.Effect<A, E>,
): Effect.Effect<A, E> =>
Effect.acquireUseRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-fork-"))),
use,
(configDirPath) =>
Effect.sync(() => {
NodeFS.rmSync(configDirPath, { recursive: true, force: true });
}),
);

const writeSourceTranscript = (configDirPath: string, projectKey = "fixture-project") => {
const projectDirectory = NodePath.join(configDirPath, "projects", projectKey);
NodeFS.mkdirSync(projectDirectory, { recursive: true });
NodeFS.writeFileSync(
NodePath.join(projectDirectory, `${SOURCE_SESSION_ID}.jsonl`),
[
`{"type":"user","uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","sessionId":"${SOURCE_SESSION_ID}","parentUuid":null,"timestamp":"2026-07-15T08:00:00.000Z","message":{"role":"user","content":"hello"}}`,
`{"type":"assistant","uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","sessionId":"${SOURCE_SESSION_ID}","parentUuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-07-15T08:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}`,
"",
].join("\n"),
);
return projectDirectory;
};

it.effect("forks a real SDK transcript inside the configured Claude config dir", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const projectDirectory = writeSourceTranscript(configDirPath);
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
configDirPath,
});

expect(result.sessionId).not.toBe(SOURCE_SESSION_ID);
expect(NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`))).toBe(
true,
);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("fails with ClaudeSessionForkError and restores the env for unknown sessions", () =>
withTempConfigDir((configDirPath) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;

const result = yield* forkClaudePersistedSession({
sessionId: "99999999-9999-4999-8999-999999999999",
configDirPath,
}).pipe(Effect.flip);

expect(result).toBeInstanceOf(ClaudeSessionForkError);
expect(result.sessionId).toBe("99999999-9999-4999-8999-999999999999");
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);

it.effect("serializes concurrent forks so each targets its own config dir", () =>
withTempConfigDir((firstConfigDir) =>
withTempConfigDir((secondConfigDir) =>
Effect.gen(function* () {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
// Passing `dir` makes the SDK resolve it (awaited realpath) before it
// reads CLAUDE_CONFIG_DIR, so an unserialized implementation would
// read the other fork's override and fail to find its transcript.
const makeWorkspace = (configDirPath: string) => {
const workspace = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(configDirPath, "ws-")),
);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const originalHome = process.env.HOME;

const result = yield* forkClaudePersistedSession({
sessionId: sourceSessionId,
environment: { ...process.env, HOME: homePath },
spawner,
});

expect(result.sessionId).not.toBe(sourceSessionId);
expect(
NodeFS.existsSync(NodePath.join(projectDirectory, `${result.sessionId}.jsonl`)),
).toBe(true);
expect(process.env.HOME).toBe(originalHome);
}),
(homePath) =>
Effect.sync(() => {
NodeFS.rmSync(homePath, { recursive: true, force: true });
}),
const projectKey = workspace.replace(/[^a-zA-Z0-9]/g, "-");
return { workspace, projectDirectory: writeSourceTranscript(configDirPath, projectKey) };
};
const first = makeWorkspace(firstConfigDir);
const second = makeWorkspace(secondConfigDir);

const [firstFork, secondFork] = yield* Effect.all(
[
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: first.workspace,
configDirPath: firstConfigDir,
}),
forkClaudePersistedSession({
sessionId: SOURCE_SESSION_ID,
dir: second.workspace,
configDirPath: secondConfigDir,
}),
],
{ concurrency: "unbounded" },
);

expect(
NodeFS.existsSync(NodePath.join(first.projectDirectory, `${firstFork.sessionId}.jsonl`)),
).toBe(true);
expect(
NodeFS.existsSync(
NodePath.join(second.projectDirectory, `${secondFork.sessionId}.jsonl`),
),
).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalConfigDir);
}),
),
);
});
),
);
Loading