Skip to content
Open
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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ sessions T3 itself spawned are skipped (`skipped-owned` — session id found in
worktrees dir; `skipped-copy` — a forkSession copy whose message uuids largely already live on
another thread), and deleted imported threads stay deleted via the event-log tombstone.

### Threads that fail with "No conversation found with session ID"

Every send in a Claude thread passes `--resume <session id>` from the thread's
`provider_session_runtime.resume_cursor_json`. If that transcript
(`~/.claude/projects/<cwd with non-alphanumerics replaced by '-'>/<session id>.jsonl`) is missing
on the host — imported threads whose transcript never lived here, a reprovisioned host — Claude Code
refuses to resume and the thread surfaces a runtime error naming the expected path
(`apps/server/src/provider/claudeSessionTranscript.ts`). The cursor is deliberately left intact so
the file can be restored from wherever the conversation ran. `t3 session audit` (offline, safe while
serving) lists every affected thread; `t3 session reset <threadId> --yes` clears one cursor so the
thread starts a fresh Claude session (`apps/server/src/cli/session.ts`).

## Upstream sync PRs: NEVER squash-merge

Sync PRs (`merge: upstream through <sha>`) must land as a **merge commit** or a
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ A **Settings → Features** page with auto/show/hide toggles for the chat header
### And more

- **Resumable Claude Code conversation import** — `t3 import claude <session>` turns an existing Claude Code transcript into a resumable T3 thread, forking to a new transcript so your original session is untouched.
- **Missing-transcript diagnostics** — when a Claude thread can no longer be resumed because its `~/.claude/projects/…/<session>.jsonl` is gone (host reprovisioned, thread imported from another machine), the thread shows exactly which file is missing instead of a generic stream failure. `t3 session audit` lists every affected thread with the path to restore; `t3 session reset <threadId> --yes` is the explicit opt-in to start that thread over with a fresh Claude session.
- **Android app** — a full native Android build (Ghostty terminal, Shiki-highlighted code blocks, themed native chrome, self-signed-TLS trust, sideload APK publish script).
- **File explorer collapsed by default**, and a fix so the **left edge of message lines is selectable**.

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { importCommand } from "./cli/import.ts";
import { projectCommand } from "./cli/project.ts";
import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
import { serviceCommand } from "./cli/service.ts";
import { sessionCommand } from "./cli/session.ts";

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);

Expand Down Expand Up @@ -50,6 +51,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
authCommand,
projectCommand,
importCommand,
sessionCommand,
serviceCommand,
cloudEnabled ? connectCommand : connectUnavailableCommand,
]),
Expand Down
32 changes: 4 additions & 28 deletions apps/server/src/cli/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import * as Schema from "effect/Schema";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import {
isNonConversationalTitle,
Expand All @@ -43,16 +42,11 @@ import {
} from "../import/syncPlan.ts";
import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
import { ProviderSessionRuntimeRepositoryLive } from "../persistence/Layers/ProviderSessionRuntime.ts";
import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts";
import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ServerSettings from "../serverSettings.ts";
import { expandHomePath } from "../os-jank.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
import { OfflineCliRuntimeLive } from "./offlineRuntime.ts";

const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CLAUDE_ADAPTER_KEY = "claudeAgent";
Expand All @@ -63,24 +57,6 @@ class ImportCommandError extends Data.TaggedError("ImportCommandError")<{
readonly message: string;
}> {}

/**
* Offline runtime for `t3 import`. Mirrors `ProjectCliRuntimeLive`
* (orchestration engine + projection snapshot + sqlite + workspace paths)
* and additionally provides the provider session directory (so we can seed
* the resume binding) and server settings (so we can resolve the Claude
* provider instance). `FileSystem`, `Path`, and `Crypto` are satisfied by the
* ambient CLI runtime layer (NodeServices) provided in `bin.ts`.
*/
const ImportCliRuntimeLive = Layer.mergeAll(
WorkspacePaths.layer,
ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)),
ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntimeRepositoryLive)),
OrchestrationLayerLive,
).pipe(
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceLayerLive),
);

const claudeModel = DEFAULT_MODEL_BY_PROVIDER[CLAUDE_DRIVER_KIND] ?? "claude-sonnet-5";

const claudeUuid = Crypto.Crypto.pipe(
Expand Down Expand Up @@ -616,7 +592,7 @@ const importClaudeCommand = Command.make("claude", {
});
}).pipe(
Effect.provide(
ImportCliRuntimeLive.pipe(
OfflineCliRuntimeLive.pipe(
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
),
Expand Down Expand Up @@ -896,7 +872,7 @@ const importSyncCommand = Command.make("sync", {
}
}).pipe(
Effect.provide(
ImportCliRuntimeLive.pipe(
OfflineCliRuntimeLive.pipe(
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
),
Expand Down Expand Up @@ -1020,7 +996,7 @@ const importRetitleCommand = Command.make("retitle", {
}
}).pipe(
Effect.provide(
ImportCliRuntimeLive.pipe(
OfflineCliRuntimeLive.pipe(
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
),
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/cli/offlineRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Layer from "effect/Layer";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
import { ProviderSessionRuntimeRepositoryLive } from "../persistence/Layers/ProviderSessionRuntime.ts";
import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";

/**
* Offline runtime for CLI subcommands that operate directly on the T3 state
* database without a running server (`t3 import`, `t3 session`). Mirrors
* `ProjectCliRuntimeLive` (orchestration engine + projection snapshot + sqlite
* + workspace paths) and additionally provides the provider session directory
* (resume bindings) and server settings (provider instance resolution).
* `FileSystem`, `Path`, and `Crypto` are satisfied by the ambient CLI runtime
* layer (NodeServices) provided in `bin.ts`. Callers still supply
* `ServerConfig` and `MinimumLogLevel`.
*
* SQLite runs in WAL mode, so these commands are safe to run while the server
* is serving; the server re-reads resume bindings from the database on every
* session start.
*/
export const OfflineCliRuntimeLive = Layer.mergeAll(
WorkspacePaths.layer,
ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)),
ProviderSessionDirectoryLive.pipe(Layer.provide(ProviderSessionRuntimeRepositoryLive)),
OrchestrationLayerLive,
).pipe(
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceLayerLive),
);
85 changes: 85 additions & 0 deletions apps/server/src/cli/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { assert, it } from "@effect/vitest";
import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts";

import type { ProviderRuntimeBinding } from "../provider/Services/ProviderSessionDirectory.ts";
import { formatAuditRow, readClaudeResumeTarget, threadLifecycle } from "./session.ts";

const THREAD_ID = ThreadId.make("thread-1");
const SESSION_ID = "9af5bb2c-886f-474a-9caa-af43d15fed38";

function claudeBinding(overrides: Partial<ProviderRuntimeBinding> = {}): ProviderRuntimeBinding {
return {
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
providerInstanceId: ProviderInstanceId.make("claudeAgent"),
resumeCursor: { threadId: THREAD_ID, resume: SESSION_ID, turnCount: 0 },
runtimePayload: { cwd: "/home/me/app", model: "claude-fable-5" },
...overrides,
};
}

it("reads the resume target from a Claude binding", () => {
assert.deepStrictEqual(readClaudeResumeTarget(claudeBinding()), {
threadId: THREAD_ID,
sessionId: SESSION_ID,
cwd: "/home/me/app",
});
});

it("keeps fork cursors (imported threads) and tolerates a missing cwd", () => {
const target = readClaudeResumeTarget(
claudeBinding({
resumeCursor: { threadId: THREAD_ID, resume: SESSION_ID, forkSession: true },
runtimePayload: null,
}),
);
assert.deepStrictEqual(target, { threadId: THREAD_ID, sessionId: SESSION_ID, cwd: undefined });
});

it("ignores non-Claude bindings and bindings without a resume id", () => {
assert.strictEqual(
readClaudeResumeTarget(claudeBinding({ provider: ProviderDriverKind.make("codex") })),
undefined,
);
assert.strictEqual(readClaudeResumeTarget(claudeBinding({ resumeCursor: null })), undefined);
assert.strictEqual(
readClaudeResumeTarget(claudeBinding({ resumeCursor: { threadId: THREAD_ID, turnCount: 2 } })),
undefined,
);
assert.strictEqual(readClaudeResumeTarget(claudeBinding({ resumeCursor: "junk" })), undefined);
});

it("classifies thread lifecycle from the projection", () => {
const base = { archivedAt: null, deletedAt: null };
assert.strictEqual(threadLifecycle(undefined), "unknown");
assert.strictEqual(threadLifecycle(base as never), "active");
assert.strictEqual(
threadLifecycle({ ...base, archivedAt: "2026-01-01T00:00:00.000Z" } as never),
"archived",
);
assert.strictEqual(
threadLifecycle({
archivedAt: "2026-01-01T00:00:00.000Z",
deletedAt: "2026-02-01T00:00:00.000Z",
} as never),
"deleted",
);
});

it("formats a missing row with the path to restore", () => {
const line = formatAuditRow({
threadId: THREAD_ID,
title: 'Second "Brain"',
lifecycle: "active",
sessionId: SESSION_ID,
cwd: "/home/me/app",
location: {
kind: "missing",
expectedPath: `/home/me/.claude/projects/-home-me-app/${SESSION_ID}.jsonl`,
},
});
assert.strictEqual(
line,
`thread=thread-1 lifecycle=active title="Second \\"Brain\\"" session=${SESSION_ID} cwd=/home/me/app expected=/home/me/.claude/projects/-home-me-app/${SESSION_ID}.jsonl`,
);
});
Loading
Loading