Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b617722
chore: ignore .claude/worktrees/ scratch directory
claude Sep 7, 2026
9e77080
feat(server): add Agent Relay provider adapter
claude Sep 7, 2026
eace4aa
Merge Agent Relay provider adapter (worktree agent)
claude Sep 7, 2026
85f8fc3
feat(server): surface externally-started Claude/Codex sessions via li…
claude Sep 7, 2026
fc70608
feat(server): auto-discover and spawn Agent Relay agents from a works…
claude Sep 7, 2026
47164e7
Merge external-session lifecycle hooks (worktree agent)
claude Sep 7, 2026
1ae7fa2
Merge Agent Relay workspace auto-discovery + spawn (worktree agent)
claude Sep 7, 2026
cad581b
fix(server): correct Agent Relay wire format to match the real broker
claude Sep 7, 2026
db79194
fix(contracts): default Agent Relay to Workspace mode, not Single
claude Sep 7, 2026
fb97ce6
feat(server): auto-materialize threads for already-running Agent Rela…
claude Sep 7, 2026
8758430
Merge auto-materialize threads for existing Agent Relay agents (workt…
claude Sep 7, 2026
f1a941f
fix(contracts): default the Add Provider wizard to Agent Relay Worksp…
claude Sep 7, 2026
a267f77
fix(shared): tolerate EAFNOSUPPORT when probing ::1 availability
claude Sep 7, 2026
8406e68
fix(server): make Agent Relay's first turn actually reach the broker
claude Sep 7, 2026
bf8ad8c
fix(server): Agent Relay adapter reconnect, turn-registration, and co…
claude Sep 7, 2026
a7d0b8c
fix(server): Agent Relay discovery reactor - listing failures and pha…
claude Sep 7, 2026
3cd49f2
fix(server): whitelist online/offline in Agent Relay presence events
claude Sep 7, 2026
b0e8878
fix(server): Agent Relay health check catches misconfiguration earlier
claude Sep 7, 2026
970304f
docs(contracts): correct stale Agent Relay defaults doc, link provide…
claude Sep 7, 2026
f6256e8
fix(server): authenticate, bound, and harden external-session hooks
claude Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ node_modules/
*.log
.env*
!.env.example
.claude/worktrees/
18 changes: 17 additions & 1 deletion apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Image } from "expo-image";
import { Path, Svg } from "react-native-svg";
import { Circle, Path, Svg } from "react-native-svg";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";

type ProviderIconProps = {
Expand Down Expand Up @@ -61,6 +61,22 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "agentrelay") {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx="5" cy="12" r="2.5" stroke={mono} strokeWidth="1.5" />
<Circle cx="19" cy="5" r="2.5" stroke={mono} strokeWidth="1.5" />
<Circle cx="19" cy="19" r="2.5" stroke={mono} strokeWidth="1.5" />
<Path
d="M7.3 10.8 16.7 6.2M7.3 13.2l9.4 4.6"
stroke={mono}
strokeWidth="1.5"
strokeLinecap="round"
/>
</Svg>
);
}

if (props.provider === "opencode") {
return (
<Svg width={size} height={size} viewBox="0 0 32 40" fill="none">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSes
import * as ProviderService from "../src/provider/Services/ProviderService.ts";
import * as ProviderSessionDirectory from "../src/provider/Services/ProviderSessionDirectory.ts";
import * as ProviderSessionReaper from "../src/provider/Services/ProviderSessionReaper.ts";
import * as AgentRelayThreadDiscoveryReactor from "../src/provider/Services/AgentRelayThreadDiscoveryReactor.ts";
import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";
import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts";
import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts";
Expand Down Expand Up @@ -79,6 +80,9 @@ const startupDependencies = Layer.mergeAll(
Layer.succeed(ProviderSessionReaper.ProviderSessionReaper, {
start: () => Effect.void,
}),
Layer.succeed(AgentRelayThreadDiscoveryReactor.AgentRelayThreadDiscoveryReactor, {
start: () => Effect.void,
}),
ServerLifecycleEvents.layer,
Layer.succeed(ServerEnvironment.ServerEnvironment, {
getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-startup-orphan")),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"test": "vp test run"
},
"dependencies": {
"@agent-relay/sdk": "^11.10.3",
"@anthropic-ai/claude-agent-sdk": "^0.3.260",
"@effect/platform-bun": "catalog:",
"@effect/platform-node": "catalog:",
Expand All @@ -33,6 +34,7 @@
"node-pty": "^1.1.0",
"stream-chain": "^4.2.5",
"stream-json": "3.6.0",
"ws": "^8.18.0",
"yaml": "catalog:",
"yauzl": "^3.4.0"
},
Expand All @@ -44,6 +46,7 @@
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
"@types/node": "catalog:",
"@types/ws": "^8.5.13",
"@types/yauzl": "^3.4.0",
"effect-acp": "workspace:*",
"effect-codex-app-server": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,76 @@ describe("ProviderCommandReactor", () => {
}),
);

effectIt.effect("refuses to start a provider session on an external-session marker thread", () =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() => createHarness());
const now = "2026-01-01T00:00:00.000Z";
// Same id shape ExternalSessionHooks.ts mints for a Claude Code /
// Codex session started outside T3 Code (see
// `externalSessionThreadId`/`isExternalSessionMarkerThreadId`).
const markerThreadId = ThreadId.make("external:claude:session-outside-t3");

yield* harness.engine.dispatch({
type: "thread.create",
commandId: CommandId.make("cmd-marker-thread-create"),
threadId: markerThreadId,
projectId: asProjectId("project-1"),
title: "External Claude Code session",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt: now,
historyImport: true,
});

yield* harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-on-marker-thread"),
threadId: markerThreadId,
message: {
messageId: asMessageId("user-message-on-marker-thread"),
role: "user",
text: "hello",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
});

yield* Effect.promise(() =>
waitFor(async () => {
const thread = (await harness.readModel()).threads.find(
(entry) => entry.id === markerThreadId,
);
return (
thread?.activities.some(
(activity) => activity.kind === "provider.turn.start.failed",
) === true
);
}),
);

const thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
(entry) => entry.id === markerThreadId,
);
expect(
thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
).toMatchObject({
summary: "Provider turn start failed",
payload: { detail: expect.stringContaining("read-only marker") },
});
expect(thread?.session).toBeNull();
expect(harness.startSession).not.toHaveBeenCalled();
expect(harness.sendTurn).not.toHaveBeenCalled();
}),
);

effectIt.effect("shows the missing workspace message without a provider stack trace", () =>
Effect.gen(function* () {
const attempted = yield* Deferred.make<void>();
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";

import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
import { isExternalSessionMarkerThreadId } from "../../project/ExternalSessionHooks.ts";
import {
ProviderAdapterRequestError,
ProviderAdapterValidationError,
Expand Down Expand Up @@ -1192,6 +1193,26 @@ const make = Effect.gen(function* () {
if (!thread) {
return;
}
// A thread minted by the external-session lifecycle hooks is a read-only
// historical marker for a session T3 Code never ran — there is no PTY or
// provider session to attach to. Nothing server-side stops a client from
// sending it a message (see ExternalSessionHooks.ts), so refuse to start
// a real provider session here instead of silently spawning one inside
// what the user sees as a read-only marker thread.
if (isExternalSessionMarkerThreadId(event.payload.threadId)) {
yield* appendProviderFailureActivity({
threadId: event.payload.threadId,
kind: "provider.turn.start.failed",
summary: "Provider turn start failed",
detail:
"This thread is a read-only marker for a session started outside T3 Code. It has no " +
"live provider session to attach to and cannot start a new one.",
turnId: null,
createdAt: event.payload.createdAt,
requestId: event.payload.messageId,
});
return;
}
const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({
threadId: thread.id,
messageId: event.payload.messageId,
Expand Down
Loading
Loading