Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/quiet-answers-land.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"t3code-cli": minor
---

add a one-shot ask command for projects and existing threads
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,22 @@ Lists available provider models. Use `--all` to include hidden or unavailable en

## Thread Workflow

### Asking One Question

`ask` waits for one answer and prints only the assistant text by default:

```sh
t3cli ask "Which package owns this API?" --project /path/to/project
t3cli ask "Summarize the decision" --thread <id>
```

Without `--thread`, it creates a thread and archives it after a successful answer. With
`--thread`, it leaves the existing thread active unless `--archive` is set explicitly.
`--archive` accepts `never`, `always`, `on-success`, or `on-failure`.

Existing busy threads are rejected. `--timeout 5m` limits the response wait. Unlike other
thread-scoped commands, `ask` uses only an explicit `--thread` and ignores `T3CODE_THREAD_ID`.

### Starting Threads

```sh
Expand Down Expand Up @@ -304,6 +320,7 @@ Most commands support:
Thread commands also support `ndjson` for streaming:

```sh
t3cli ask "task" --format ndjson
t3cli start "task" --format ndjson --wait
t3cli wait --format ndjson
```
Expand Down
8 changes: 4 additions & 4 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
nixpkgs-darwin.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin";

upstream-t3code = {
url = "github:pingdotgg/t3code/3b72d17cbca691f0b64e6d4a10c9e349f42873a5";
url = "github:pingdotgg/t3code/f0ebc628c6dd83fd0c7963078ad7778ce6028d0c";
flake = false;
};
};
Expand Down
13 changes: 13 additions & 0 deletions skills/t3code-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ t3cli start "task" --format json --wait
t3cli start "task" --format ndjson --wait # stream events
```

**Ask once**

Use `ask` when only the final answer is needed. It creates and archives a temporary thread by
default, or sends to an explicit existing thread without archiving it:

```sh
t3cli ask "question" --project <project-ref>
t3cli ask "follow-up" --thread <thread-id>
```

`ask` ignores `T3CODE_THREAD_ID`; pass `--thread` intentionally. Plain output is answer text only.
Use `--format json` for a result object or `--format ndjson` for streamed events.

**Follow-up**

```sh
Expand Down
29 changes: 29 additions & 0 deletions skills/t3code-cli/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ Mutations dispatch `project.meta.update` with the next full scripts array and wa
t3cli list [--project <ref>] [--archived | --all] [--format json]
t3cli search <query> [--limit <1-50>] [--format auto|human|json]

t3cli ask [message]
[--project <ref>] [--thread <id>] [--force|-f] [--stdin]
[--title <title>] [--worktree <path>] [--provider <name>] [--model <id>]
[--option key=value] [--reasoning-effort <v>] [--effort <v>] [--fast-mode] [--thinking]
[--archive never|always|on-success|on-failure]
[--timeout <duration>]
[--format auto|human|json|ndjson]

t3cli start [message]
[--project <ref>] [--stdin] [--title <title>] [--worktree <path>]
[--provider <name>] [--model <id>]
Expand All @@ -115,6 +123,22 @@ t3cli transcript [--thread <id>] [--limit N]
t3cli wait [--thread <id>] [--format auto|human|ndjson]
```

`ask` always waits for the turn it starts. Without `--thread`, it creates a thread and defaults to
`--archive on-success`. With an explicit `--thread`, it defaults to `--archive never`. An explicit
archive policy applies to either target. Archive failures produce a warning and remain visible in
structured output without changing a successful exit status.

Existing busy or archived threads and threads with pending approval or user-input requests are
rejected.

`--timeout` accepts positive durations such as `30s`, `5m`, and `1h`; omitting it waits without a
limit. A timeout or local interruption stops a turn started by `ask` when ownership can be
confirmed, then applies the failure archive policy.

`--title`, `--worktree`, `--provider`, and `--model` apply only when creating a thread and are
rejected with `--thread`. Model option flags apply to both modes. `ask` never reads
`T3CODE_THREAD_ID`; selecting an existing thread requires `--thread`.

`transcript` loads the latest 10 user turns by default. Older-page requests default to 20 turns.
JSON output includes `page.beforeCursor` and `page.hasMore`; pass the cursor to `--before-cursor` to
load the next older page. `--turn-limit` sets either page size. `--all` loads the full thread and
Expand Down Expand Up @@ -185,6 +209,7 @@ t3cli thread callback --from <thread-id> --prompt <message> [--thread <id>] [--b
| Commands | `--format` | Agent default |
| --------------- | ----------------------------- | ------------------------------- |
| Most | `auto` \| `human` \| `json` | `json` |
| `ask` | + `ndjson` | `human` |
| `start`, `send` | + `ndjson` | `json` / `ndjson` with `--wait` |
| `wait` | `auto` \| `human` \| `ndjson` | `ndjson` |

Expand All @@ -202,6 +227,10 @@ One JSON object per line:
{ "type": "done", "thread": {}, "latestAssistantMessage": {} }
```

Successful `ask --format ndjson` streams the same thread events and ends with a `result` object.
`ask --format json` returns one object with `answer`, `threadId`, `turnId`, `created`, `dispatch`,
and `archive`. Human output writes progress to stderr and only the final answer to stdout.

## Examples

```sh
Expand Down
20 changes: 17 additions & 3 deletions src/application/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,13 @@ export type UpdateThreadInput = {
readonly worktreePath?: string | null;
};

export type StartThreadPolicy = {
export interface ThreadDispatchPolicy {
readonly until: "dispatch" | "visible" | "complete";
};
}

export interface StartThreadPolicy extends ThreadDispatchPolicy {
readonly onThreadCreated?: (threadId: string) => Effect.Effect<void>;
}

export type WaitEvent =
| { readonly type: "thread"; readonly thread: OrchestrationThread }
Expand Down Expand Up @@ -209,6 +213,7 @@ export class T3ProjectApplication extends Context.Service<
>()("t3cli/T3ProjectApplication") {}

export type T3ThreadApplicationService = {
readonly awaitShellSequence: (sequence: number) => Effect.Effect<void, ApplicationError>;
readonly searchThreads: (
input: OrchestrationSearchThreadsInput,
) => Effect.Effect<ReadonlyArray<ThreadSearchResult>, ApplicationError>;
Expand All @@ -227,6 +232,9 @@ export type T3ThreadApplicationService = {
readonly getThreadMessages: (
input: GetThreadMessagesInput,
) => Effect.Effect<OrchestrationThreadDetailSnapshot, ApplicationError>;
readonly getThreadSummary: (
threadId: string,
) => Effect.Effect<OrchestrationThreadShell, ApplicationError>;
readonly showThread: (threadId: string) => Effect.Effect<ThreadShow, ApplicationError>;
readonly approveThread: (input: {
readonly threadId: string;
Expand All @@ -246,6 +254,10 @@ export type T3ThreadApplicationService = {
>;
readonly archiveThread: (threadId: string) => Effect.Effect<DispatchResult, ApplicationError>;
readonly interruptThread: (threadId: string) => Effect.Effect<DispatchResult, ApplicationError>;
readonly interruptThreadTurn: (
threadId: string,
turnId: string,
) => Effect.Effect<DispatchResult | undefined, ApplicationError>;
readonly pinThread: (threadId: string) => Effect.Effect<DispatchResult, ApplicationError>;
readonly settleThread: (threadId: string) => Effect.Effect<DispatchResult, ApplicationError>;
readonly snoozeThread: (
Expand All @@ -270,6 +282,7 @@ export type T3ThreadApplicationService = {
) => Effect.Effect<
{
readonly dispatch: DispatchResult;
readonly messageId: string;
readonly project: OrchestrationProjectShell;
readonly threadId: string;
readonly thread?: OrchestrationThread;
Expand All @@ -278,10 +291,11 @@ export type T3ThreadApplicationService = {
>;
readonly sendThread: (
input: SendThreadInput,
policy?: StartThreadPolicy,
policy?: ThreadDispatchPolicy,
) => Effect.Effect<
{
readonly dispatch: DispatchResult;
readonly messageId: string;
readonly threadId: string;
readonly thread?: OrchestrationThread;
},
Expand Down
66 changes: 52 additions & 14 deletions src/application/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
type ListThreadsInclude,
type SnoozeThreadInput,
type StartThreadInput,
type StartThreadPolicy,
type ThreadDispatchPolicy,
} from "./service.ts";
import type { CallbackThreadInput, SendThreadInput } from "./service.ts";
import type { T3ThreadApplicationService } from "./service.ts";
Expand Down Expand Up @@ -117,6 +119,23 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
) {
return yield* orchestration.getThreadDetailSnapshot(input);
});
const getThreadSummary = Effect.fn("T3ApplicationLive.getThreadSummary")(function* (
threadId: string,
) {
const snapshot = yield* loadThreadsSnapshot("all").pipe(
Effect.provideService(T3Orchestration, orchestration),
);
const thread = snapshot.threads.find((item) => item.id === threadId);
if (thread === undefined) {
return yield* Effect.fail(
new ThreadLookupError({
message: `thread not found: ${threadId}`,
threadId,
}),
);
}
return thread;
});
const showThread = Effect.fn("T3ApplicationLive.showThread")(function* (threadId: string) {
const thread = yield* orchestration.getThreadSnapshot(threadId);
return projectThreadShow(thread);
Expand All @@ -125,7 +144,9 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
const command = yield* makeThreadArchiveCommand(threadId).pipe(
Effect.provideService(Crypto.Crypto, crypto),
);
return yield* orchestration.dispatch(command);
const dispatch = yield* orchestration.dispatch(command);
yield* awaitShellSequence(dispatch.sequence);
return dispatch;
});
const unarchiveThread = Effect.fn("T3ApplicationLive.unarchiveThread")(function* (
threadId: string,
Expand Down Expand Up @@ -188,6 +209,19 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
}).pipe(Effect.provideService(Crypto.Crypto, crypto));
return yield* orchestration.dispatch(command);
});
const interruptThreadTurn = Effect.fn("T3ApplicationLive.interruptThreadTurn")(function* (
threadId: string,
turnId: string,
) {
const snapshot = yield* orchestration.getThreadSnapshot(threadId);
if (snapshot.session?.activeTurnId !== turnId) {
return undefined;
}
const command = yield* makeThreadInterruptCommand({ threadId, turnId }).pipe(
Effect.provideService(Crypto.Crypto, crypto),
);
return yield* orchestration.dispatch(command);
});
const deleteThread = Effect.fn("T3ApplicationLive.deleteThread")(function* (threadId: string) {
const snapshot = yield* loadThreadsSnapshot("all").pipe(
Effect.provideService(T3Orchestration, orchestration),
Expand Down Expand Up @@ -220,9 +254,7 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
);
const startThread = Effect.fn("T3ApplicationLive.startThread")(function* (
startInput: StartThreadInput,
policy?: {
readonly until: "dispatch" | "visible" | "complete";
},
policy?: StartThreadPolicy,
) {
const snapshot = yield* orchestration.getShellSnapshot();
const projectRef = startInput.projectRef;
Expand Down Expand Up @@ -252,13 +284,17 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
project: scope.project,
serverConfig,
}).pipe(Effect.provideService(Crypto.Crypto, crypto));
const threadId = commands.threadId;
const createDispatch = yield* orchestration.dispatch(commands.createCommand);
if (policy?.onThreadCreated !== undefined) {
yield* policy.onThreadCreated(threadId);
}
yield* awaitShellSequence(createDispatch.sequence);
const dispatch = yield* orchestration.dispatch(commands.turnCommand);
const threadId = commands.threadId;
const messageId = commands.turnCommand.message.messageId;
const until = policy?.until ?? "dispatch";
if (until === "dispatch") {
return { dispatch, project: scope.project, threadId };
return { dispatch, messageId, project: scope.project, threadId };
}
yield* awaitShellSequence(dispatch.sequence);
if (until === "visible") {
Expand All @@ -268,17 +304,15 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
return opened.snapshot;
}),
);
return { dispatch, project: scope.project, threadId, thread };
return { dispatch, messageId, project: scope.project, threadId, thread };
}
const thread = yield* awaitThreadCompletion(threadId);
yield* failIfThreadError(thread);
return { dispatch, project: scope.project, threadId, thread };
return { dispatch, messageId, project: scope.project, threadId, thread };
});
const sendThread = Effect.fn("T3ApplicationLive.sendThread")(function* (
input: SendThreadInput,
policy?: {
readonly until: "dispatch" | "visible" | "complete";
},
policy?: ThreadDispatchPolicy,
) {
const modelSelection =
input.options !== undefined && input.options.length > 0
Expand All @@ -292,9 +326,10 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
...(modelSelection !== undefined ? { modelSelection } : {}),
}).pipe(Effect.provideService(Crypto.Crypto, crypto));
const dispatch = yield* orchestration.dispatch(command);
const messageId = command.message.messageId;
const until = policy?.until ?? "dispatch";
if (until === "dispatch") {
return { dispatch, threadId: input.threadId };
return { dispatch, messageId, threadId: input.threadId };
}
yield* awaitShellSequence(dispatch.sequence);
if (until === "visible") {
Expand All @@ -304,11 +339,11 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
return opened.snapshot;
}),
);
return { dispatch, threadId: input.threadId, thread };
return { dispatch, messageId, threadId: input.threadId, thread };
}
const thread = yield* awaitThreadCompletion(input.threadId);
yield* failIfThreadError(thread);
return { dispatch, threadId: input.threadId, thread };
return { dispatch, messageId, threadId: input.threadId, thread };
});
const watchThread = (threadId: string) => streamThreadEvents(threadId);
const waitForThread = Effect.fn("T3ApplicationLive.waitForThread")(function* (threadId: string) {
Expand Down Expand Up @@ -352,8 +387,10 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
return {
approveThread,
archiveThread,
awaitShellSequence,
deleteThread,
interruptThread,
interruptThreadTurn,
pinThread,
settleThread,
snoozeThread,
Expand All @@ -365,6 +402,7 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function
listThreads,
searchThreads,
getThreadMessages,
getThreadSummary,
respondToThread,
sendThread,
showThread,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from "effect/unstable/cli";

import { createActionCommand } from "./action.ts";
import { askCommand } from "./ask.ts";
import { createAuthCommand } from "./auth.ts";
import { createEnvCommand } from "./env.ts";
import { cliEnvironmentSetting } from "./env/flag.ts";
Expand All @@ -22,6 +23,7 @@ export function createCliCommand() {
Command.withGlobalFlags([cliEnvironmentSetting]),
Command.withSubcommands([
createActionCommand(),
askCommand,
createAuthCommand(),
createEnvCommand(),
listThreadsCommand,
Expand Down
Loading
Loading