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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# T3 Code

T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients.
T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, Kimi, OpenCode) and serves web, desktop, and mobile clients.

You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.

Expand Down Expand Up @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste

- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Providers.** Codex, Claude, Cursor, Grok, Kimi, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).

Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Kimi Code CLI, and OpenCode. If they're set up on your computer, T3 Code can control them.

## "Wait, what are you selling me?"

Expand All @@ -13,12 +13,13 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Kimi Code CLI, and OpenCode. Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
> - Kimi: install [Kimi Code CLI](https://moonshotai.github.io/kimi-code/en/) and run `kimi` then `/login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
### Try it out (install-free)
Expand Down
19 changes: 16 additions & 3 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,13 +283,26 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
{ modelId: "grok-mock-alt", name: "Grok Mock Alt" },
];

/** Prefixed ids used by Kimi ACP resolution (`resolveKimiAcpBaseModelId`). */
const kimiAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
{ modelId: "kimi-code/kimi-for-coding", name: "Kimi for Coding" },
{ modelId: "kimi-code/kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" },
{ modelId: "kimi-code/k3", name: "K3" },
{ modelId: "kimi-code/k3-256k", name: "K3 256k" },
// Allows tests that pass short Grok mock ids to still succeed after Kimi prefixing.
{ modelId: "kimi-code/grok-mock-alt", name: "Grok Mock Alt (Kimi-prefixed)" },
{ modelId: "kimi-code/grok-build", name: "Grok Build (Kimi-prefixed)" },
];

const knownAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [...grokAcpModels, ...kimiAcpModels];

function modelState(): AcpSchema.SessionModelState {
const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)
const modelId = knownAcpModels.some((model) => model.modelId === currentModelId)
? currentModelId
: "grok-build";
return {
currentModelId: modelId,
availableModels: grokAcpModels,
availableModels: knownAcpModels,
};
}

Expand Down Expand Up @@ -382,7 +395,7 @@ const program = Effect.gen(function* () {

yield* agent.handleSetSessionModel((request) =>
Effect.gen(function* () {
if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {
if (!knownAcpModels.some((model) => model.modelId === request.modelId)) {
return yield* AcpError.AcpRequestError.invalidParams(
`Unknown mock model id: ${request.modelId}`,
{
Expand Down
197 changes: 197 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ApprovalRequestId,
type AssistantDeliveryMode,
CommandId,
EventId,
MessageId,
type OrchestrationEvent,
type OrchestrationMessage,
Expand Down Expand Up @@ -93,6 +94,8 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120);
const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000;
const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120);
const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;
/** Cap for per-turn reasoning/thought buffers (same order as assistant text). */
const MAX_BUFFERED_REASONING_CHARS = 24_000;
const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0";

type TurnStartRequestedDomainEvent = Extract<
Expand Down Expand Up @@ -726,6 +729,17 @@ const make = Effect.gen(function* () {
lookup: () => Effect.succeed({ text: "", createdAt: "" }),
});

// Streaming reasoning/thought chunks (ACP agent_thought_chunk, Claude thinking, …)
// are buffered per turn and projected as thinking worklog activity.
const bufferedReasoningByTurnKey = yield* Cache.make<
string,
{ text: string; started: boolean; lastPublishedLength: number }
>({
capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,
timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL,
lookup: () => Effect.succeed({ text: "", started: false, lastPublishedLength: 0 }),
});

// Task names arrive on task.started/task.progress but not on task.completed,
// so remember them per task to title the completion activity.
const taskDescriptionByTaskKey = yield* Cache.make<string, string>({
Expand All @@ -737,6 +751,150 @@ const make = Effect.gen(function* () {
const rememberTaskDescription = (threadId: ThreadId, taskId: string, description: string) =>
Cache.set(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId), description);

const reasoningTaskIdForTurn = (turnId: TurnId) => `reasoning:${turnId}`;
const REASONING_PROGRESS_PUBLISH_CHARS = 280;

const appendReasoningDelta = (input: {
readonly event: ProviderRuntimeEvent;
readonly threadId: ThreadId;
readonly turnId: TurnId;
readonly delta: string;
readonly createdAt: string;
}) =>
Effect.gen(function* () {
const key = providerTurnKey(input.threadId, input.turnId);
const existing = yield* Cache.getOption(bufferedReasoningByTurnKey, key).pipe(
Effect.map((option) =>
Option.getOrElse(option, () => ({ text: "", started: false, lastPublishedLength: 0 })),
),
);
// Cap buffer growth so long thought streams cannot exhaust server memory.
let nextText = `${existing.text}${input.delta}`;
if (nextText.length > MAX_BUFFERED_REASONING_CHARS) {
nextText = nextText.slice(0, MAX_BUFFERED_REASONING_CHARS);
}
const taskId = reasoningTaskIdForTurn(input.turnId);
const activities: Array<OrchestrationThreadActivity> = [];
const hasVisibleText = nextText.trim().length > 0;
// Only open a Thinking task once there is non-whitespace content so a
// whitespace-only stream never leaves a dangling "Thinking" entry.
const shouldStart = !existing.started && hasVisibleText;
const shouldPublishProgress =
hasVisibleText &&
(shouldStart ||
nextText.length - existing.lastPublishedLength >= REASONING_PROGRESS_PUBLISH_CHARS);

if (shouldStart) {
activities.push({
id: EventId.make(`${input.event.eventId}:reasoning-started`),
createdAt: input.createdAt,
tone: "info",
kind: "task.started",
summary: "Thinking",
payload: {
taskId,
taskType: "reasoning",
detail: "Thinking…",
},
turnId: input.turnId,
});
}

if (shouldPublishProgress) {
activities.push({
id: EventId.make(`${input.event.eventId}:reasoning-progress`),
createdAt: input.createdAt,
tone: "info",
kind: "task.progress",
summary: "Thinking",
payload: {
taskId,
title: "Thinking",
detail: truncateDetail(nextText),
summary: truncateDetail(nextText, 160),
},
turnId: input.turnId,
});
}

const started = existing.started || shouldStart;
yield* Cache.set(bufferedReasoningByTurnKey, key, {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
text: nextText,
started,
// Only advance when progress was actually published (not on whitespace skips).
lastPublishedLength: shouldPublishProgress ? nextText.length : existing.lastPublishedLength,
});
Comment thread
cursor[bot] marked this conversation as resolved.
if (started) {
yield* rememberTaskDescription(input.threadId, taskId, "Thinking");
}

yield* Effect.forEach(
activities,
(activity) =>
providerCommandId(input.event, "reasoning-activity").pipe(
Effect.flatMap((commandId) =>
orchestrationEngine.dispatch({
type: "thread.activity.append",
commandId,
threadId: input.threadId,
activity,
createdAt: activity.createdAt,
}),
),
),
{ concurrency: 1 },
);
});

const completeReasoningForTurn = (input: {
readonly event: ProviderRuntimeEvent;
readonly threadId: ThreadId;
readonly turnId: TurnId;
readonly createdAt: string;
}) =>
Effect.gen(function* () {
const key = providerTurnKey(input.threadId, input.turnId);
const existing = yield* Cache.getOption(bufferedReasoningByTurnKey, key).pipe(
Effect.map(Option.getOrUndefined),
);
if (!existing?.started) {
// Drop any whitespace-only buffer that never opened a UI task.
if (existing) {
yield* Cache.invalidate(bufferedReasoningByTurnKey, key);
}
return;
}
const taskId = reasoningTaskIdForTurn(input.turnId);
const detail = truncateDetail(existing.text.trim());
yield* Cache.invalidate(bufferedReasoningByTurnKey, key);
// Always close the task if we opened it, even when the visible detail is empty.
yield* providerCommandId(input.event, "reasoning-complete").pipe(
Effect.flatMap((commandId) =>
orchestrationEngine.dispatch({
type: "thread.activity.append",
commandId,
threadId: input.threadId,
activity: {
id: EventId.make(`${input.event.eventId}:reasoning-completed`),
createdAt: input.createdAt,
tone: "info",
kind: "task.completed",
summary: "Thinking complete",
payload: {
taskId,
status: "completed",
title: "Thinking",
summary: detail.length > 0 ? detail : "Thinking complete",
detail: detail.length > 0 ? detail : "Thinking complete",
},
turnId: input.turnId,
},
createdAt: input.createdAt,
}),
),
);
});

// Entries are left in place after completion so replayed or duplicate
// terminal events stay titled; TTL, capacity, and the session-exit sweep
// bound the cache.
Expand Down Expand Up @@ -1172,6 +1330,7 @@ const make = Effect.gen(function* () {
const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey));
const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById));
const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey));
const reasoningKeys = Array.from(yield* Cache.keys(bufferedReasoningByTurnKey));
yield* Effect.forEach(
turnKeys,
(key) =>
Expand Down Expand Up @@ -1213,6 +1372,12 @@ const make = Effect.gen(function* () {
key.startsWith(prefix) ? Cache.invalidate(taskDescriptionByTaskKey, key) : Effect.void,
{ concurrency: 1 },
).pipe(Effect.asVoid);
yield* Effect.forEach(
reasoningKeys,
(key) =>
key.startsWith(prefix) ? Cache.invalidate(bufferedReasoningByTurnKey, key) : Effect.void,
{ concurrency: 1 },
).pipe(Effect.asVoid);
});

const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn(
Expand Down Expand Up @@ -1458,11 +1623,37 @@ const make = Effect.gen(function* () {
event.type === "content.delta" && event.payload.streamKind === "assistant_text"
? event.payload.delta
: undefined;
const reasoningDelta =
event.type === "content.delta" && event.payload.streamKind === "reasoning_text"
? event.payload.delta
: undefined;
const proposedPlanDelta =
event.type === "turn.proposed.delta" ? event.payload.delta : undefined;

if (reasoningDelta && reasoningDelta.length > 0) {
const turnId = toTurnId(event.turnId);
if (turnId) {
yield* appendReasoningDelta({
event,
threadId: thread.id,
turnId,
delta: reasoningDelta,
createdAt: now,
});
}
}

if (assistantDelta && assistantDelta.length > 0) {
const turnId = toTurnId(event.turnId);
// First visible answer token closes the thinking worklog for this turn.
if (turnId) {
yield* completeReasoningForTurn({
event,
threadId: thread.id,
turnId,
createdAt: now,
});
}
const assistantMessageId = yield* getOrCreateAssistantMessageId({
threadId: thread.id,
event,
Expand Down Expand Up @@ -1641,6 +1832,12 @@ const make = Effect.gen(function* () {
const proposedPlans = detailedThread?.proposedPlans ?? [];
const turnId = toTurnId(event.turnId);
if (turnId) {
yield* completeReasoningForTurn({
event,
threadId: thread.id,
turnId,
createdAt: now,
});
const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
yield* Effect.forEach(
assistantMessageIds,
Expand Down
Loading
Loading