Skip to content
Closed
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
7 changes: 4 additions & 3 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, Hermes Agent, 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, Hermes Agent, 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`
> - Hermes Agent: install [Hermes](https://hermes-agent.nousresearch.com/docs/user-guide/installation) and run `hermes setup`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`

### Try it out (install-free)
Expand Down Expand Up @@ -82,7 +83,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
- Providers: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) · [Hermes](./docs/user/providers-hermes.md)
- Linux: [run T3 Code as a background service](./docs/user/background-service.md)

Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md).
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
[WS_METHODS.serverImportHermesSessions]: AuthOrchestrationOperateScope,
[WS_METHODS.serverImportLocalChats]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,9 @@ const make = Effect.gen(function* () {
>,
) {
if (event.type === "thread.message-sent") {
if (event.metadata.importedHistory === true) {
return;
}
if (
event.payload.role !== "user" ||
event.payload.streaming ||
Expand Down
42 changes: 42 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
}

case "thread.message-sent":
case "thread.history-imported":
case "thread.proposed-plan-upserted":
case "thread.activity-appended":
case "thread.approval-response-requested":
Expand Down Expand Up @@ -986,6 +987,28 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
return;
}

case "thread.history-imported": {
yield* Effect.forEach(
event.payload.messages,
(message) =>
projectionThreadMessageRepository.upsert({
messageId: message.id,
threadId: event.payload.threadId,
turnId: message.turnId,
role: message.role,
text: message.text,
...(message.attachments !== undefined
? { attachments: [...message.attachments] }
: {}),
isStreaming: false,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
}),
{ concurrency: 16, discard: true },
);
return;
}

case "thread.reverted": {
const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
threadId: event.payload.threadId,
Expand Down Expand Up @@ -1095,6 +1118,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
});
return;

case "thread.history-imported":
yield* Effect.forEach(
event.payload.activities,
(activity) =>
projectionThreadActivityRepository.upsert({
activityId: activity.id,
threadId: event.payload.threadId,
turnId: activity.turnId,
tone: activity.tone,
kind: activity.kind,
summary: activity.summary,
payload: activity.payload,
...(activity.sequence !== undefined ? { sequence: activity.sequence } : {}),
createdAt: activity.createdAt,
}),
{ concurrency: 16, discard: true },
);
return;

case "thread.reverted": {
const existingRows = yield* projectionThreadActivityRepository.listByThreadId({
threadId: event.payload.threadId,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
ThreadHistoryImportedPayload as ContractsThreadHistoryImportedPayloadSchema,
ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema,
Expand Down Expand Up @@ -50,6 +51,7 @@ export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;

export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
export const ThreadHistoryImportedPayload = ContractsThreadHistoryImportedPayloadSchema;
export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema;
export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;
Expand Down
131 changes: 131 additions & 0 deletions apps/server/src/orchestration/decider.importMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
CommandId,
EventId,
MessageId,
ProjectId,
ProviderInstanceId,
ThreadId,
type OrchestrationEvent,
type OrchestrationReadModel,
} from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import { decideOrchestrationCommand } from "./decider.ts";

const NOW = "2026-08-15T12:00:00.000Z";
const THREAD_ID = ThreadId.make("hermes-thread");

const readModel: OrchestrationReadModel = {
snapshotSequence: 0,
projects: [],
threads: [
{
id: THREAD_ID,
projectId: ProjectId.make("hermes-project"),
title: "Imported Hermes chat",
modelSelection: {
instanceId: ProviderInstanceId.make("hermes"),
model: "hermes-agent",
},
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
latestTurn: null,
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
settledOverride: null,
settledAt: null,
snoozedUntil: null,
snoozedAt: null,
pinnedAt: null,
deletedAt: null,
messages: [],
proposedPlans: [],
activities: [],
checkpoints: [],
session: null,
},
],
updatedAt: NOW,
};

it.layer(NodeServices.layer)("Hermes message import decider", (it) => {
it.effect("preserves the imported role, text, id, and timestamp", () =>
Effect.gen(function* () {
const result = yield* decideOrchestrationCommand({
command: {
type: "thread.message.import",
commandId: CommandId.make("import-message"),
threadId: THREAD_ID,
messageId: MessageId.make("hermes-message"),
role: "assistant",
text: "Imported response",
createdAt: NOW,
},
readModel,
});

const events: ReadonlyArray<OrchestrationEvent> = Array.isArray(result)
? result
: [result as OrchestrationEvent];
expect(events).toHaveLength(1);
const event = events[0];
if (event?.type === "thread.message-sent") {
expect(event.metadata.importedHistory).toBe(true);
expect(event.payload).toMatchObject({
threadId: THREAD_ID,
messageId: MessageId.make("hermes-message"),
role: "assistant",
text: "Imported response",
streaming: false,
createdAt: NOW,
updatedAt: NOW,
});
}
}),
);

it.effect("imports a complete message and activity history atomically", () =>
Effect.gen(function* () {
const result = yield* decideOrchestrationCommand({
command: {
type: "thread.history.import",
commandId: CommandId.make("import-history"),
threadId: THREAD_ID,
messages: [
{
messageId: MessageId.make("history-message"),
role: "user",
text: "Imported prompt",
createdAt: NOW,
},
],
activities: [
{
id: EventId.make("history-activity"),
tone: "tool",
kind: "tool.completed",
summary: "pnpm build",
payload: { itemType: "command_execution" },
turnId: null,
sequence: 0,
createdAt: NOW,
},
],
},
readModel,
});
const events = Array.isArray(result) ? result : [result];
expect(events.map((event) => event.type)).toEqual(["thread.history-imported"]);
expect(events[0]?.metadata.importedHistory).toBe(true);
if (events[0]?.type === "thread.history-imported") {
expect(events[0].payload.messages).toHaveLength(1);
expect(events[0].payload.activities).toHaveLength(1);
}
}),
);
});
66 changes: 66 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,72 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
};
}

case "thread.message.import": {
yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
return {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
metadata: { importedHistory: true },
})),
type: "thread.message-sent",
payload: {
threadId: command.threadId,
messageId: command.messageId,
role: command.role,
text: command.text,
attachments: command.role === "user" ? [] : undefined,
turnId: null,
streaming: false,
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
};
}

case "thread.history.import": {
yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
const updatedAt = [...command.messages, ...command.activities].reduce(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration/decider.ts:1317

An empty thread.history.import emits thread.history-imported with updatedAt and occurredAt set to 1970-01-01T00:00:00.000Z, rewinding the existing thread and making it sort as decades old. Reject empty imports before the reduction so the sentinel is never persisted.

Also found in 1 other location(s)

packages/contracts/src/orchestration.ts:1008

ThreadHistoryImportCommand accepts a history with both messages and activities empty. On that valid input, the decider's max-date reduction falls back to 1970-01-01T00:00:00.000Z, emits thread.history-imported, and the projector replaces the existing thread's updatedAt with that epoch value. An empty/fully-filtered imported session can therefore make an existing thread appear decades old and disrupt updated-time ordering; require at least one imported item or make an empty import a no-op.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 1317:

An empty `thread.history.import` emits `thread.history-imported` with `updatedAt` and `occurredAt` set to `1970-01-01T00:00:00.000Z`, rewinding the existing thread and making it sort as decades old. Reject empty imports before the reduction so the sentinel is never persisted.

Also found in 1 other location(s):
- packages/contracts/src/orchestration.ts:1008 -- `ThreadHistoryImportCommand` accepts a history with both `messages` and `activities` empty. On that valid input, the decider's max-date reduction falls back to `1970-01-01T00:00:00.000Z`, emits `thread.history-imported`, and the projector replaces the existing thread's `updatedAt` with that epoch value. An empty/fully-filtered imported session can therefore make an existing thread appear decades old and disrupt updated-time ordering; require at least one imported item or make an empty import a no-op.

(latest, item) => (item.createdAt > latest ? item.createdAt : latest),
"1970-01-01T00:00:00.000Z",
);
return {
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: updatedAt,
commandId: command.commandId,
metadata: { importedHistory: true },
})),
type: "thread.history-imported",
payload: {
threadId: command.threadId,
messages: command.messages.map((message) => ({
id: message.messageId,
role: message.role,
text: message.text,
...(message.role === "user" ? { attachments: [] } : {}),
turnId: null,
streaming: false,
createdAt: message.createdAt,
updatedAt: message.createdAt,
})),
activities: command.activities,
updatedAt,
},
};
}

case "thread.proposed-plan.upsert": {
yield* requireThread({
readModel,
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema";
import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts";
import {
MessageSentPayloadSchema,
ThreadHistoryImportedPayload,
ProjectCreatedPayload,
ProjectDeletedPayload,
ProjectMetaUpdatedPayload,
Expand Down Expand Up @@ -549,6 +550,29 @@ export function projectEvent(
};
});

case "thread.history-imported":
return decodeForEvent(
ThreadHistoryImportedPayload,
event.payload,
event.type,
"payload",
).pipe(
Effect.map((payload) => {
const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
if (!thread) return nextBase;
return {
...nextBase,
threads: updateThread(nextBase.threads, payload.threadId, {
messages: [...thread.messages, ...payload.messages].slice(-MAX_THREAD_MESSAGES),
activities: [...thread.activities, ...payload.activities]
.toSorted(compareThreadActivities)
.slice(-500),
updatedAt: payload.updatedAt,
}),
};
}),
);

case "thread.session-set":
return Effect.gen(function* () {
const payload = yield* decodeForEvent(
Expand Down
Loading
Loading