Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod

- **Removed worktree recovery**. Follow-up messages continue the same conversation in the main project checkout when a worktree is gone. A T3 notice appears as a user message and tells the agent where it is running. Settings → Extras → **Skip recreating removed worktrees** is on by default. Turn it off to try recreation first, with the main checkout as a fallback if recreation fails. Mobile exposes the same shared setting under Settings → Thread behavior.

- **Snooze until it's done** — an "Until it's done" preset in the thread snooze menus, offered only while the agent is mid-turn, hides the thread until that turn ends: it returns when the agent finishes, is interrupted, fails, or asks you something, with the usual Woke marker. These rows sit at the top of the Snoozed section with an "until done" label; the T3 CLI reports them as `Snoozed: until done`. Available on web, desktop, and mobile against servers that carry the feature.
- **Snooze until it's done** — an "Until it's done" preset in the thread snooze menus, offered while the agent is mid-turn or its subagents are still working, hides the thread until that work ends, including the agent's follow-up on the subagents' results. Watch loops such as a dev server don't hold it. The thread returns when the agent finishes, is interrupted, fails, or asks you something, with the usual Woke marker. These rows sit at the top of the Snoozed section with an "until done" label; the T3 CLI reports them as `Snoozed: until done`. Available on web, desktop, and mobile against servers that carry the feature.
- **Indefinite snooze** — an "Until I wake it" preset in the thread sidebar snooze menus parks a thread on the snoozed shelf with no timer: it comes back when explicitly woken, when you send it a message, or when the thread needs you (a finished or interrupted turn, a failure, or a pending request). Indefinite rows sort after every timed wake and carry a "parked" label; the T3 CLI reports them as `Snoozed: until woken`.
- **Conversation forking** — fork a Codex or Claude thread mid-conversation into a new thread, including immediately after importing an external session. Claude copies the full native history using the same bundled helper as rewind, preserving the selected provider instance and matching conversation turns even when native system records change; forked threads are titled with a 🔱 prefix.
- **Two-pane split view** — web and desktop can show a second thread beside the routed one, including a thread from a different environment. Open it from the chat header button (between the git actions and the terminal toggle) or the ⌘K "Open thread in split view..." action. While split, hovering the divider reveals its controls near the top: swap the two threads (also ⌘K "Swap split threads", rebindable, default mod+shift+\), switch the right pane's thread, and close the split. Sidebar clicks and ⌘K thread picks open into the active pane — picking a thread already on screen just focuses its pane, so neither path can accidentally fold the split — and the sidebar marks both open threads with left/right pane glyphs. A ring marks the active pane, a rebindable shortcut (default mod+\) jumps between panes, and window shortcuts — including permission-prompt number keys — act only on the active pane. Each pane's right panel opens as an overlay sheet, terminals stay with the pane that owns their thread, the divider drags and remembers its position, and actions that create a new thread (new thread, implement plan) stay in the main pane.
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,10 @@ const makeOrchestrationEngine = Effect.gen(function* () {
: Option.none();
const eventBase = yield* decideOrchestrationCommand({
command: envelope.command,
hasLiveBackgroundWork:
"threadId" in envelope.command &&
threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !==
null,
backgroundLiveness:
"threadId" in envelope.command
? threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId)
: null,
readModel: Option.isSome(completionCheckpoints)
? {
...commandReadModel,
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/orchestration/ThreadSettlementPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,28 @@ describe("resolveAutoSettlementAt", () => {
),
).toBe(false);
expect(isThreadSnoozed(makeThread({ ...untilDone, latestTurn: null }), NOW)).toBe(false);
// An ended turn whose subagents still work stays snoozed; watch loops
// alone don't hold it.
const endedTurn = {
turnId: TurnId.make("turn-done"),
state: "completed" as const,
requestedAt: "2026-08-18T00:00:00.000Z",
startedAt: "2026-08-18T00:01:00.000Z",
completedAt: "2026-08-20T00:00:00.000Z",
assistantMessageId: null,
};
expect(
isThreadSnoozed(
makeThread({ ...untilDone, latestTurn: endedTurn, backgroundLiveness: "working" }),
NOW,
),
).toBe(true);
expect(
isThreadSnoozed(
makeThread({ ...untilDone, latestTurn: endedTurn, backgroundLiveness: "monitoring" }),
NOW,
),
).toBe(false);
});

it("allows a fresh completion to wake snooze before settlement", () => {
Expand Down
31 changes: 23 additions & 8 deletions apps/server/src/orchestration/ThreadSettlementPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now:
return !isThreadSnoozed(thread, now);
}

/**
* Server twin of client-runtime's untilDoneWorkContinues: the awaited turn
* is running, or it ended while its subagents work on. Watch loops alone
* ("monitoring") don't count. Keep the two in step.
*/
export function untilDoneWorkContinues(
thread: Pick<
OrchestrationThreadShell,
"snoozedUntilTurnId" | "latestTurn" | "backgroundLiveness"
>,
): boolean {
return (
thread.snoozedUntilTurnId != null &&
thread.latestTurn?.turnId === thread.snoozedUntilTurnId &&
(thread.latestTurn.state === "running" || thread.backgroundLiveness === "working")
);
}

/**
* Server twin of client-runtime's effectiveSnoozed: hidden while the wake
* condition holds and the thread has not raised its hand. A raised hand is
Expand All @@ -148,6 +166,7 @@ export function isThreadSnoozed(
| "hasPendingUserInput"
| "session"
| "latestTurn"
| "backgroundLiveness"
>,
now: string,
): boolean {
Expand All @@ -159,6 +178,10 @@ export function isThreadSnoozed(
) {
return false;
}
// "Until it's done": snoozed only while the awaited work continues. An
// ended turn is not news while its subagents still work; any other shape
// (ended and quiet, replaced, dropped) wakes.
if (thread.snoozedUntilTurnId != null) return untilDoneWorkContinues(thread);
if (
thread.snoozedAt != null &&
thread.latestTurn != null &&
Expand All @@ -168,14 +191,6 @@ export function isThreadSnoozed(
) {
return false;
}
// "Until it's done": snoozed only while the awaited turn is still the
// running latest turn. Any other shape (ended, replaced, dropped) wakes.
if (thread.snoozedUntilTurnId != null) {
return (
thread.latestTurn?.turnId === thread.snoozedUntilTurnId &&
thread.latestTurn.state === "running"
);
}
if (thread.snoozedUntil == null) return thread.snoozedAt != null;
return Date.parse(thread.snoozedUntil) > Date.parse(now);
}
89 changes: 89 additions & 0 deletions apps/server/src/orchestration/decider.snoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ const RUNNING_TURN: NonNullable<OrchestrationThread["latestTurn"]> = {
completedAt: null,
assistantMessageId: null,
};
// Settled after the snooze was set, with subagents possibly still working.
const SETTLED_TURN: NonNullable<OrchestrationThread["latestTurn"]> = {
...RUNNING_TURN,
state: "completed",
completedAt: "1969-12-31T00:00:00.000Z",
};

function makeReadModel(input: {
readonly snoozedUntil?: string | null;
Expand Down Expand Up @@ -444,6 +450,89 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => {
}),
);

it.effect("snoozes until done on a settled turn while its subagents work, not watch loops", () =>
Effect.gen(function* () {
const command = {
type: "thread.snooze",
commandId: CommandId.make("cmd-snooze-until-done-background"),
threadId: ThreadId.make("thread-1"),
snoozedUntil: null,
untilDone: true,
} as const;
const readModel = makeReadModel({ latestTurn: SETTLED_TURN });
const event = yield* decideOrchestrationCommand({
command,
readModel,
backgroundLiveness: "working",
});
const events = Array.isArray(event) ? event : [event];
expect(events[0]?.type === "thread.snoozed" && events[0].payload.snoozedUntilTurnId).toBe(
SETTLED_TURN.turnId,
);
const error = yield* decideOrchestrationCommand({
command,
readModel,
backgroundLiveness: "monitoring",
}).pipe(Effect.flip);
expect(error._tag).toBe("OrchestrationCommandInvariantError");
}),
);

it.effect(
"moves an until-done snooze onto the agent's follow-up turn only while work is live",
() =>
Effect.gen(function* () {
const followUpTurnId = TurnId.make("turn-2");
const command = {
type: "thread.session.set",
commandId: CommandId.make("cmd-session-follow-up"),
threadId: ThreadId.make("thread-1"),
session: {
threadId: ThreadId.make("thread-1"),
status: "running",
providerName: "claudeAgent",
runtimeMode: "full-access",
activeTurnId: followUpTurnId,
lastError: null,
updatedAt: NOW_EPOCH,
},
createdAt: NOW_EPOCH,
} as const;
const readModel = makeReadModel({
snoozedAt: SNOOZED_AT,
snoozedUntilTurnId: "turn-1",
latestTurn: SETTLED_TURN,
});

const live = yield* decideOrchestrationCommand({
command,
readModel,
backgroundLiveness: "working",
});
const liveEvents = Array.isArray(live) ? live : [live];
expect(liveEvents.map((event) => event.type)).toEqual([
"thread.session-set",
"thread.snoozed",
]);
const moved = liveEvents[1];
if (moved?.type !== "thread.snoozed") throw new Error("expected thread.snoozed");
expect(moved.payload.snoozedUntilTurnId).toBe(followUpTurnId);
expect(moved.payload.snoozedAt).toBe(SNOOZED_AT);

// The work went quiet before this turn: the thread already woke.
for (const backgroundLiveness of [null, "monitoring"] as const) {
const quiet = yield* decideOrchestrationCommand({
command,
readModel,
backgroundLiveness,
});
expect((Array.isArray(quiet) ? quiet : [quiet]).map((event) => event.type)).toEqual([
"thread.session-set",
]);
}
}),
);

it.effect("rejects until-done combined with a wake time", () =>
Effect.gen(function* () {
const error = yield* decideOrchestrationCommand({
Expand Down
67 changes: 57 additions & 10 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type OrchestrationEvent,
type OrchestrationReadModel,
type OrchestrationThread,
type OrchestrationThreadShell,
type ThreadPullRequestKey,
type ThreadPullRequestLink,
type OrchestrationThreadActivity,
Expand Down Expand Up @@ -238,12 +239,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
command,
readModel,
userInputActivity,
hasLiveBackgroundWork = false,
backgroundLiveness = null,
}: {
readonly command: OrchestrationCommand;
readonly readModel: OrchestrationReadModel;
readonly userInputActivity?: OrchestrationThreadActivity;
readonly hasLiveBackgroundWork?: boolean;
/** The command thread's live background work (engine state, not read model). */
readonly backgroundLiveness?: OrchestrationThreadShell["backgroundLiveness"];
}): Effect.fn.Return<
DecideOrchestrationCommandResult,
OrchestrationCommandRejection | PlatformError.PlatformError,
Expand Down Expand Up @@ -797,7 +799,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
requestedAt: occurredAt,
status: "pending",
});
if (turnId !== null || hasLiveBackgroundWork) return request;
if (turnId !== null || backgroundLiveness != null) return request;
return [
request,
{
Expand Down Expand Up @@ -1156,13 +1158,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`,
});
}
// "Until it's done" waits on the running turn. Without one there is
// nothing to finish and the snooze would never wake, so reject rather
// than silently parking the thread indefinitely. Background work that
// outlives a settled turn does not count: the wake rule reads
// latestTurn, and that turn has already ended.
// "Until it's done" waits on the running turn, or on the subagents a
// settled turn left working. With neither there is nothing to finish
// and the snooze would never wake, so reject rather than silently
// parking the thread indefinitely. Watch loops alone ("monitoring")
// don't count: a dev server can outlive the session.
const untilDoneTurnId =
command.untilDone === true && thread.latestTurn?.state === "running"
command.untilDone === true &&
thread.latestTurn !== null &&
(thread.latestTurn.state === "running" || backgroundLiveness === "working")
? thread.latestTurn.turnId
: null;
if (command.untilDone === true && command.snoozedUntil !== null) {
Expand All @@ -1174,7 +1178,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
if (command.untilDone === true && untilDoneTurnId === null) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `thread ${command.threadId} has no running turn to wait for`,
detail: `thread ${command.threadId} has no running turn or working subagents to wait for`,
});
}
// Re-snoozing an already-snoozed thread to the SAME wake condition is
Expand All @@ -1195,6 +1199,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
snoozedUntilTurnId: thread.snoozedUntilTurnId,
session: thread.session,
latestTurn: thread.latestTurn,
backgroundLiveness,
// Open requests were already rejected above.
hasPendingApprovals: false,
hasPendingUserInput: false,
Expand Down Expand Up @@ -2643,6 +2648,48 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
},
};
const sessionEvents = [sessionSetEvent];
// A turn the agent starts on its own (its answer to finished subagents)
// continues the work an "until it's done" snooze waits on: move the
// snooze onto it while that work is still live. A user's turn start
// unsnoozes first, and a turn arriving after the work went quiet finds
// the thread already awake, so both stay out.
const followUpTurnId = command.session.activeTurnId;
if (
followUpTurnId !== null &&
thread.snoozedUntilTurnId != null &&
thread.snoozedAt != null &&
followUpTurnId !== thread.snoozedUntilTurnId &&
isThreadSnoozed(
{
snoozedUntil: thread.snoozedUntil,
snoozedAt: thread.snoozedAt,
snoozedUntilTurnId: thread.snoozedUntilTurnId,
session: thread.session,
latestTurn: thread.latestTurn,
backgroundLiveness,
hasPendingApprovals: openRequests(thread).size > 0,
hasPendingUserInput: false,
},
command.createdAt,
)
) {
sessionEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt: command.createdAt,
commandId: command.commandId,
})),
type: "thread.snoozed",
payload: {
threadId: command.threadId,
snoozedUntil: null,
snoozedAt: thread.snoozedAt,
snoozedUntilTurnId: followUpTurnId,
updatedAt: command.createdAt,
},
});
}
// An idle archive has no selected turn to compare at execution. A new
// provider turn must cancel it even when no user turn-start was sent.
if (
Expand Down
Loading