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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,51 @@ Command flags override the CLI config, which overrides the T3 project's saved mo

Speed and thinking effort are stored as model options. T3 applies the option ids supported by the selected provider/model. If `--provider` changes the project's default provider instance, also pass `--model` because provider instance ids can be user-defined and do not imply a model.

## Continue an existing thread

Use the **T3 thread ID** from a previous command's JSON `data.thread.id` or the
thread's browser URL (not its provider session ID):

```bash
t3code --json threads send \
--thread-id <thread-id> \
--prompt "Continue from our previous discussion." \
--open none

t3code threads send --thread-id <thread-id> --stdin --open none < follow-up.txt
t3code threads send --thread-id <thread-id> --prompt-file follow-up.txt --dry-run
```

`threads send` submits another user message to the existing conversation. It
retains the thread's workspace, title, model/provider options, permission level,
and interaction mode. CLI defaults for creating threads do not override these
settings. Exactly one of `--prompt`, `--prompt-file`, or `--stdin` is required.

The target must exist and be unarchived. Choose how to handle active work:

- `--if-busy reject` (default) returns `THREAD_BUSY` without sending the prompt
when the thread has an active or pending turn.
- `--if-busy inject` dispatches the prompt immediately even when the thread is
busy, letting T3 and its provider handle the message during active work.

```bash
t3code threads send --thread-id <thread-id> --if-busy inject \
--prompt "Additional context for the work in progress…" --open none
```

Neither mode adds a CLI queue or explicitly interrupts the turn. Injection does
not guarantee identical steering behavior across providers or special turn types;
T3/provider errors can still occur after dispatch acceptance. Missing or archived
threads still fail with `THREAD_NOT_FOUND` or `THREAD_ARCHIVED` in either mode.
The busy check in reject mode is a snapshot check, not an atomic lock: concurrent
senders must serialize their requests. Provider session resumption and
conversation context are managed by T3 and the provider.

`--dry-run` validates the target and prints the proposed command without sending
it or opening the UI. Success means T3 accepted the dispatch, not that the agent
finished its turn. A failed or timed-out dispatch never deletes the existing
thread; check T3 before retrying an ambiguous failure to avoid duplicate prompts.

## Settings

```bash
Expand Down Expand Up @@ -137,6 +182,7 @@ t3code projects list
t3code projects resolve --cwd .
t3code projects ensure --cwd . --project-policy create
t3code threads create --stdin
t3code threads send --thread-id <thread-id> --stdin
t3code handover --stdin
t3code request get /api/orchestration/snapshot
```
Expand Down
26 changes: 25 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
listProjects,
rawGet,
resolveProject,
sendThreadPrompt,
type ThreadCreateOptions,
} from "./service.js";
import type {
Expand Down Expand Up @@ -254,7 +255,30 @@ addProjectPolicyOption(addWorkspaceOptions(projects.command("ensure")))
}),
);

const threads = program.command("threads").description("Create T3 Code threads.");
const threads = program.command("threads").description("Create and continue T3 Code threads.");
threads.command("send")
.description("Send a prompt to an existing T3 thread, preserving its settings.")
.requiredOption("--thread-id <id>", "Existing T3 thread ID (not the provider session ID).")
.option("--prompt <text>", "Prompt to send.")
.option("--prompt-file <path>", "Read the prompt from a UTF-8 file.")
.option("--stdin", "Read the prompt from stdin.")
.addOption(new Option("--if-busy <behavior>", "Reject a busy thread or inject into its active work.").choices(["reject", "inject"]).default("reject"))
.addOption(new Option("--open <mode>").choices(["auto", "desktop", "browser", "none"]))
.option("--dry-run", "Validate the target and print the command without dispatching it.")
.action((options: ThreadCommandOptions & { threadId: string; ifBusy: "reject" | "inject" }) =>
action(async () => {
const context = await commandContext();
const result = await sendThreadPrompt(context.config, {
threadId: options.threadId,
ifBusy: options.ifBusy,
prompt: await resolvePrompt(options),
...(options.open ? { openMode: options.open } : {}),
...(options.dryRun ? { dryRun: true } : {}),
});
writeSuccess(result, context, `${result.dryRun ? "Would send" : "Sent"} prompt to thread ${result.thread.id}.`);
}),
);

addThreadOptions(threads.command("create"))
.description("Create a new project thread and start its first turn.")
.action((options: ThreadCommandOptions) =>
Expand Down
153 changes: 150 additions & 3 deletions src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_CONFIG } from "./config.js";
import { CliError } from "./errors.js";
import { runProcess } from "./process.js";
import { createHandoverThread } from "./service.js";
import { createHandoverThread, sendThreadPrompt } from "./service.js";
import type { CliConfig, T3Project, T3Thread } from "./types.js";

const cleanup: Array<() => Promise<void>> = [];
Expand All @@ -35,6 +35,7 @@ async function testHarness(
failTurn?: boolean;
serverVersion?: string;
settings?: Record<string, unknown>;
shellUnavailable?: boolean;
} = {},
) {
const root = await mkdtemp(path.join(os.tmpdir(), "t3code-cli-service-"));
Expand Down Expand Up @@ -63,7 +64,11 @@ async function testHarness(
json(response, 401, { error: "unauthorized" });
return;
}
if (request.method === "GET" && request.url === "/api/orchestration/shell") {
if (request.url === "/api/orchestration/shell" && options.shellUnavailable) {
json(response, 404, { error: "not found" });
return;
}
if (request.method === "GET" && ["/api/orchestration/shell", "/api/orchestration/snapshot"].includes(request.url ?? "")) {
json(response, 200, {
snapshotSequence: commands.length,
projects,
Expand Down Expand Up @@ -112,7 +117,7 @@ async function testHarness(
}
if (command.type === "thread.turn.start" && options.failTurn) {
const index = threads.findIndex((thread) => thread.id === command.threadId);
if (index >= 0) threads.splice(index, 1);
if (index >= 0 && command.bootstrap) threads.splice(index, 1);
json(response, 500, { error: "turn failed" });
return;
}
Expand Down Expand Up @@ -512,3 +517,145 @@ describe("createHandoverThread", () => {
expect(harness.threads).toHaveLength(0);
});
});

function existingThread(overrides: Partial<T3Thread> = {}): T3Thread {
return {
id: "existing-thread",
projectId: "existing-project",
title: "Existing conversation",
archivedAt: null,
runtimeMode: "approval-required",
interactionMode: "plan",
modelSelection: { instanceId: "custom-provider", model: "saved-model" },
session: { status: "ready", activeTurnId: null },
latestTurn: { state: "completed" },
...overrides,
};
}

describe("sendThreadPrompt", () => {
it.each([
{ session: { status: "starting" } },
{ session: { status: "running", activeTurnId: "active-turn" } },
{ session: { status: "ready", activeTurnId: "active-turn" } },
{ latestTurn: { state: "pending" } },
{ latestTurn: { state: "running" } },
] satisfies Array<Partial<T3Thread>>)("allows explicit injection into busy state %j", async (state) => {
const harness = await testHarness();
harness.threads.push(existingThread(state));
await expect(sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Reject this", ifBusy: "reject",
})).rejects.toMatchObject({ code: "THREAD_BUSY" });
expect(harness.commands).toEqual([]);
const preview = await sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Preview injection", ifBusy: "inject", dryRun: true,
});
expect(preview.thread.dispatch).toBeNull();
expect(harness.commands).toEqual([]);
await sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Incorporate this update", ifBusy: "inject",
});
expect(harness.commands).toHaveLength(1);
expect(harness.commands[0]).toMatchObject({
type: "thread.turn.start", threadId: "existing-thread",
runtimeMode: "approval-required", interactionMode: "plan",
message: { text: "Incorporate this update" },
});
expect(harness.commands[0]).not.toHaveProperty("modelSelection");
expect(harness.threads).toEqual([existingThread(state)]);
});

it.each([
[{ archivedAt: "2026-01-01T00:00:00Z" }, "THREAD_ARCHIVED"],
[{ deletedAt: "2026-01-01T00:00:00Z" }, "THREAD_NOT_FOUND"],
] satisfies Array<[Partial<T3Thread>, string]>)("injection still rejects unavailable targets %j", async (state, code) => {
const harness = await testHarness();
harness.threads.push(existingThread(state));
await expect(sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Update", ifBusy: "inject",
})).rejects.toMatchObject({ code });
expect(harness.commands).toEqual([]);
});

it("sends only a turn to the exact existing thread and preserves its settings", async () => {
const harness = await testHarness();
harness.threads.push(existingThread());
const result = await sendThreadPrompt({ ...harness.config, model: "creation-default", runtimeMode: "full-access" }, {
threadId: "existing-thread", prompt: "Continue the previous discussion. $HOME `literal`",
});
expect(harness.commands).toHaveLength(1);
expect(harness.commands[0]).toMatchObject({
type: "thread.turn.start", threadId: "existing-thread",
runtimeMode: "approval-required", interactionMode: "plan",
message: { role: "user", text: "Continue the previous discussion. $HOME `literal`", attachments: [] },
});
for (const key of ["modelSelection", "bootstrap", "titleSeed"]) {
expect(harness.commands[0]).not.toHaveProperty(key);
}
expect(harness.threads).toEqual([existingThread()]);
expect(result.thread.id).toBe("existing-thread");
expect(result.thread.dispatch).toEqual({ sequence: 1 });
expect(result.opened.kind).toBe("none");
expect(JSON.stringify(result)).not.toContain("mock-token");
});

it("validates and previews without dispatching, including snapshot fallback", async () => {
const harness = await testHarness([], { shellUnavailable: true });
harness.threads.push(existingThread({ session: { status: "stopped", activeTurnId: null } }));
const result = await sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Preview", dryRun: true, openMode: "browser",
});
expect(harness.commands).toEqual([]);
expect(result.thread.command.message.text).toBe("Preview");
expect(result.thread.dispatch).toBeNull();
expect(result.opened.kind).toBe("none");
});

it.each([
[{ archivedAt: "2026-01-01T00:00:00Z" }, "THREAD_ARCHIVED"],
[{ deletedAt: "2026-01-01T00:00:00Z" }, "THREAD_NOT_FOUND"],
[{ session: { status: "starting" } }, "THREAD_BUSY"],
[{ session: { status: "running" } }, "THREAD_BUSY"],
[{ session: { status: "ready", activeTurnId: "active-turn" } }, "THREAD_BUSY"],
[{ latestTurn: { state: "pending" } }, "THREAD_BUSY"],
[{ latestTurn: { state: "running" } }, "THREAD_BUSY"],
] satisfies Array<[Partial<T3Thread>, string]>)("rejects unavailable thread state %j", async (overrides, code) => {
const harness = await testHarness();
harness.threads.push(existingThread(overrides));
await expect(sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Do work",
})).rejects.toMatchObject({ code });
expect(harness.commands).toEqual([]);
});

it("rejects missing threads and incomplete settings without creating or changing anything", async () => {
const harness = await testHarness();
await expect(sendThreadPrompt(harness.config, {
threadId: "missing", prompt: "Do work",
})).rejects.toMatchObject({ code: "THREAD_NOT_FOUND" });
const thread = existingThread();
delete thread.runtimeMode;
harness.threads.push(thread);
await expect(sendThreadPrompt(harness.config, {
threadId: thread.id, prompt: "Do work",
})).rejects.toMatchObject({ code: "T3_INVALID_SNAPSHOT" });
expect(harness.commands).toEqual([]);
});

it("does not delete the existing thread when dispatch fails", async () => {
const harness = await testHarness([], { failTurn: true });
harness.threads.push(existingThread());
await expect(sendThreadPrompt(harness.config, {
threadId: "existing-thread", prompt: "Do work",
})).rejects.toMatchObject({ code: "T3_API_ERROR" });
expect(harness.commands.map((command) => command.type)).toEqual(["thread.turn.start"]);
expect(harness.threads).toEqual([existingThread()]);
});

it.each([
[" ", "hello", "THREAD_ID_REQUIRED"],
["existing-thread", " \n ", "PROMPT_REQUIRED"],
])("rejects invalid input before contacting T3", async (threadId, prompt, code) => {
await expect(sendThreadPrompt(DEFAULT_CONFIG, { threadId, prompt })).rejects.toMatchObject({ code });
});
});
71 changes: 71 additions & 0 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export interface ThreadCreateOptions extends WorkspaceOptions {
dryRun?: boolean;
}

export interface ThreadSendOptions {
threadId: string;
prompt: string;
ifBusy?: "reject" | "inject";
openMode?: OpenMode;
dryRun?: boolean;
}

interface EffectiveT3Settings {
defaultThreadEnvMode: EffectiveThreadEnvMode;
newWorktreesStartFromOrigin: boolean;
Expand Down Expand Up @@ -508,6 +516,69 @@ export async function createHandoverThread(config: CliConfig, options: ThreadCre
return { ...result, opened, dryRun: options.dryRun ?? false };
}

export async function sendThreadPrompt(config: CliConfig, options: ThreadSendOptions) {
const threadId = options.threadId.trim();
if (!threadId) throw new CliError("THREAD_ID_REQUIRED", "A non-empty T3 thread ID is required.");
const prompt = options.prompt.trim();
if (!prompt) throw new CliError("PROMPT_REQUIRED", "A non-empty prompt is required.");
const ifBusy = options.ifBusy ?? "reject";
if (ifBusy !== "reject" && ifBusy !== "inject") {
throw new CliError("INVALID_THREAD_OPTION", "ifBusy must be reject or inject.");
}

const runtime = await discoverRuntime(config, { startDesktopIfNeeded: false });
const result = await withT3Api(runtime, config, async (api, invocation) => {
const shell = await api.shellSnapshot().catch(() => null);
const snapshot = shell && Array.isArray(shell.threads) ? shell : await api.snapshot();
if (!Array.isArray(snapshot.threads)) {
throw new CliError("T3_INVALID_SNAPSHOT", "T3 returned a snapshot without threads.");
}
const thread = snapshot.threads.find((entry) => entry.id === threadId && entry.deletedAt == null);
if (!thread) throw new CliError("THREAD_NOT_FOUND", `No T3 Code thread exists with ID ${threadId}.`);
if (thread.archivedAt != null) {
throw new CliError("THREAD_ARCHIVED", "Unarchive the thread in T3 Code before sending a prompt.", {
details: { threadId },
});
}
const busy =
thread.session?.status === "starting" || thread.session?.status === "running" ||
thread.session?.activeTurnId != null ||
thread.latestTurn?.state === "pending" || thread.latestTurn?.state === "running";
if (busy && ifBusy === "reject") {
throw new CliError("THREAD_BUSY", "The thread has an active or pending turn. Retry when idle or use --if-busy inject.", {
details: { threadId },
});
}
if (
!["approval-required", "auto-accept-edits", "full-access"].includes(thread.runtimeMode ?? "") ||
!["default", "plan"].includes(thread.interactionMode ?? "")
) {
throw new CliError("T3_INVALID_SNAPSHOT", "T3 returned a thread without valid permission and interaction modes.");
}
// Omit modelSelection so T3 retains the thread's model and provider options.
// Creation defaults must not change an existing conversation's permissions.
const command = {
type: "thread.turn.start",
commandId: randomUUID(),
threadId,
message: { messageId: randomUUID(), role: "user", text: prompt, attachments: [] },
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
createdAt: new Date().toISOString(),
};
const dispatch = options.dryRun ? null : await api.dispatch(command);
return {
runtime,
auth: { source: invocation.source, version: invocation.version },
thread: { id: threadId, title: thread.title, command, dispatch },
};
});
const opened = options.dryRun
? { mode: options.openMode ?? config.openMode, kind: "none" as const, url: null, exactThread: false }
: await openThread(options.openMode ?? config.openMode, runtime, threadId);
return { ...result, opened, dryRun: options.dryRun ?? false };
}

export async function rawGet(config: CliConfig, requestPath: string) {
if (!requestPath.startsWith("/") || requestPath.startsWith("//")) {
throw new CliError("INVALID_REQUEST_PATH", "Request path must start with one slash.");
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export interface T3Thread {
projectId: string;
title: string;
archivedAt: string | null;
deletedAt?: string | null;
runtimeMode?: RuntimeMode;
interactionMode?: InteractionMode;
session?: { status: string; activeTurnId?: string | null } | null;
latestTurn?: { state: string } | null;
[key: string]: unknown;
}

Expand Down