Threads, provider tasks, plans, skills, and subagents
A T3 thread is the durable work record. Provider task and subagent activity is normalized into activities and roster projections; high-frequency progress and usage streams are bounded, while liveness and plan progress stay explicitly ephemeral.
What this chapter resolves
- Separate the durable thread work record from provider task and subagent observations.
- Trace task lifecycle normalization into activities, bounded latest-state streams, the Agents projection, and the quiet work log.
- Identify which liveness and plan-progress indicators disappear on restart or settlement.
- Explain lifecycle, pin, and snooze as thread overlays rather than scheduler state.
“Task” is overloaded in agent products. Here, a thread is the durable work item: it has a project, title, model selection, runtime and interaction modes, conversation, activities, checkpoints, session summary, and lifecycle fields. A provider task or subagent is observed runtime activity inside that thread, not a second durable cross-provider work-item aggregate.
This lets a provider expose a fleet, workflow, child agent, background shell, or nothing at all without forcing every provider into a false scheduler schema. T3 can retain useful normalized evidence while preserving provider task identity and provenance in an activity payload.
The durable record is the thread
The thread read model holds message/activity history, latest turn, checkpoints,
proposed plans, session summary, and lifecycle overlays. It is the address used for
routing, recovery, and cross-surface navigation. A task id is not promoted to that
role: it travels inside normalized task.started, task.progress, task.updated,
and task.completed activities.
Thread lifecycle is durable. Archive, settled/active override, and delete have
their own state. Snooze is explicitly an overlay on an active thread: it suppresses
the inbox until snoozedUntil passes or activity wakes it. Pinning is another
overlay: active pinned threads render in a pinned block, while settled and snoozed
threads remain in their own shelves. A fractional pin order key lets a client move
one pin without rewriting its neighbors.
These overlays are separate fields, but their commands deliberately coordinate visibility state. Settling also unpins and unsnoozes; pinning unsets settlement and unsnoozes. Snooze rejects pending approval/input and queued-start windows, yet it can hide a thread whose provider session is already running. That coupling organizes the inbox; it still does not schedule or cancel native provider work.
Native activity becomes product evidence; high-frequency streams are bounded
Adapters translate native task/subagent updates into canonical task events. Runtime
ingestion turns those into thread activities. High-frequency progress uses a stable
per-thread/per-task id, so a meaningful tick replaces latest state instead of
creating an unbounded timeline. Typed usage has a second stable id, and agent-owned
tool heartbeats have another stable latest-state shape. By contrast, task.started,
task.updated, and task.completed keep the provider event id. They are retained
activities, but this code does not collapse all lifecycle evidence into one bounded
row. A usage-only update cannot erase the latest status line, and a status update
cannot erase the last known task usage.
const linkage = taskLinkageActivityFields(event.payload as Record<string, unknown>);
// Usage and activity are independent latest-state streams. Keeping them
// under separate stable ids prevents a command/reasoning update from
// replacing the last known token count (and prevents a usage-only tick
// from blanking the last meaningful activity).
const identityLinkage = { ...linkage };
delete identityLinkage.typedUsage;
delete identityLinkage.status;
delete identityLinkage.error;
const title =
event.payload.description.trim().length > 0
? { title: truncateDetail(event.payload.description, 120) }
: {};
const hasProgressState =
event.payload.typedUsage === undefined ||
event.payload.summary !== undefined ||
event.payload.lastToolName !== undefined ||
event.payload.status !== undefined ||
event.payload.error !== undefined;
return [
...(hasProgressState
? [
{
// Stable per-task id: activity is "latest state", not
// history, so each meaningful tick replaces the last. This
// bounds a large fleet to one activity row per task.
id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
createdAt: event.createdAt,
tone: "info" as const,
kind: "task.progress" as const,
summary:
event.payload.description.trim().length > 0
? truncateDetail(event.payload.description, 120)
: "Reasoning update",
payload: {
taskId: event.payload.taskId,
...title,
detail: truncateDetail(event.payload.summary ?? event.payload.description),
...(event.payload.summary
? { summary: truncateDetail(event.payload.summary) }
: {}),
...(event.payload.lastToolName
? { lastToolName: event.payload.lastToolName }
: {}),
...(event.payload.status ? { status: event.payload.status } : {}),
...(event.payload.error ? { error: event.payload.error } : {}),
...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
...identityLinkage,
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
]
: []),
...(event.payload.typedUsage !== undefined
? [
{
id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`),
createdAt: event.createdAt,
tone: "info" as const,
kind: "task.progress" as const,
summary: "Task usage updated",
payload: {
taskId: event.payload.taskId,
...title,
...identityLinkage,
usageSnapshot: true,
typedUsage: event.payload.typedUsage,
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
]
: []),
];
}
case "task.updated": {The payload can retain task type, agent linkage, title, role, model, effort,
tool-use id, workflow/phase metadata, output file, status, error, and typed usage.
The server stamps agentKind, so clients do not reverse-engineer an agent from a
provider-specific task-type vocabulary. Legacy rows without that server stamp fall
back to background classification and therefore do not enter the current Agents
roster merely because their task type looks agent-like.
/**
* Typed per-task usage rollup. Field names match the orchestration-v2 subagent
* usage vocabulary (#4779) so the eventual migration is a rename, not a remap.
* Claude reports per-activation deltas; Codex reports cumulative totals — the
* merge strategy is provider-specific and lives in client-runtime.
*/
export const RuntimeTaskUsage = Schema.Struct({
totalTokens: NonNegativeInt,
inputTokens: Schema.optional(NonNegativeInt),
cachedInputTokens: Schema.optional(NonNegativeInt),
outputTokens: Schema.optional(NonNegativeInt),
reasoningOutputTokens: Schema.optional(NonNegativeInt),
toolUses: Schema.optional(NonNegativeInt),
durationMs: Schema.optional(NonNegativeInt),
});
export type RuntimeTaskUsage = typeof RuntimeTaskUsage.Type;
export const TaskWorkflowPhase = Schema.Struct({
index: NonNegativeInt,
title: TrimmedNonEmptyStringSchema,
});
export type TaskWorkflowPhase = typeof TaskWorkflowPhase.Type;
export const TaskRunHandles = Schema.Struct({
runId: Schema.optional(TrimmedNonEmptyStringSchema),
scriptPath: Schema.optional(TrimmedNonEmptyStringSchema),
transcriptDir: Schema.optional(TrimmedNonEmptyStringSchema),
/** Only http/https URLs may be stored here — sanitized at the adapter. */
sessionUrl: Schema.optional(TrimmedNonEmptyStringSchema),
});
export type TaskRunHandles = typeof TaskRunHandles.Type;
/**
* Watch-loop task types: Monitor-tool tasks plus background shells (a shell
* that outlives its turn is in practice a watch loop). Canonical single copy —
* the server liveness registry, ingestion's agentKind stamp, and the client
* fold's legacy fallback all classify with these sets.
*/
export const MONITOR_TASK_TYPES: ReadonlySet<string> = new Set([
"monitor",
"monitor_mcp",
"local_bash",
"shell",
]);
/** Task types that are neither agents nor watch loops (plan-mode bookkeeping). */
export const INERT_TASK_TYPES: ReadonlySet<string> = new Set(["plan", "dream"]);
/**
* Agent-vs-background classification, stamped by ingestion as `agentKind` so
* persisted rows are self-describing. A deliberate denylist: the SDK's
* agent-flavored type names drift (subagent, local_agent, local_workflow, …)
* and an allowlist silently dropped real subagents when "local_agent"
* appeared. A task launched from inside a subagent (agentId set) is
* agent-internal background work UNLESS it is itself agent-flavored — a
* nested agent can outlive its parent and stays in the roster.
*/
export function classifyTaskAgentKind(input: {
readonly taskType?: string | undefined;
readonly agentId?: string | undefined;
}): "agent" | "background" {
const { taskType, agentId } = input;
const nonAgentType =
taskType !== undefined && (MONITOR_TASK_TYPES.has(taskType) || INERT_TASK_TYPES.has(taskType));
if (agentId !== undefined && agentId.trim().length > 0) {
return taskType === undefined || nonAgentType ? "background" : "agent";
}
return nonAgentType ? "background" : "agent";
}
/**
* Optional agent-identity linkage carried on every task lifecycle payload.
* Repeated on progress and terminal rows (not just start) so client folds can
* reconstruct an agent even when its start row aged out of activity retention.Codex and Claude have concrete task normalization paths in the pinned source. Codex turns collaboration-agent notifications into task lifecycle/progress events. Claude maps SDK task, workflow, and member progress messages into task events, including typed usage when present. This audit does not establish equivalent task-rollup coverage for Cursor, Grok, or OpenCode; absence here is an evidence boundary, not a claim they can never support it.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Native Codex or Claude task signals pass through provider adapters into canonical task events. Runtime ingestion creates thread activities. Progress, usage, and agent tool heartbeat streams use stable latest-state identifiers; lifecycle start, update, and completion retain provider event identifiers. The web Agents panel and web/mobile work logs derive different views from those activities. A separate branch updates in-memory liveness and plan progress, which disappear on restart or settlement according to service rules.
apps/server/src/provider/Layers/CodexAdapter.ts:503–750 ↗apps/server/src/provider/Layers/ClaudeAdapter.ts:2985–3318 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:316–359 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:541–742 ↗packages/client-runtime/src/state/subagentRuntime.ts:449–679 ↗apps/web/src/session-logic.ts:783–1115 ↗apps/mobile/src/lib/threadActivity.ts:283–470 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:1957–2026 ↗The Agents view is a projection, not a second log
The client folds retained task.* and agent-owned tool.progress activities into
a source-neutral subagent model. It accepts pending, running, waiting, idle,
completed, failed, cancelled, and interrupted; idle is explicitly resumable rather
than automatically “working.” The web Agents panel displays stable spawn order,
current activity, model/effort, token count, tool-use count, and elapsed time. Its
elapsed clock is presentation state: settled rows freeze at completion.
The fold is tolerant of missing starts, late completions, reactivation, and session death. Task usage is parsed field by field and max-merged so duplicate or late cumulative frames cannot shrink a known count or erase a known breakdown.
function asUsage(value: unknown): SubagentUsage | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
const totalTokens = asCount(record.totalTokens);
if (totalTokens === undefined) {
return undefined;
}
const usage: {
totalTokens: number;
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
reasoningOutputTokens?: number;
toolUses?: number;
durationMs?: number;
} = { totalTokens };
const inputTokens = asCount(record.inputTokens);
if (inputTokens !== undefined) usage.inputTokens = inputTokens;
const cachedInputTokens = asCount(record.cachedInputTokens);
if (cachedInputTokens !== undefined) usage.cachedInputTokens = cachedInputTokens;
const outputTokens = asCount(record.outputTokens);
if (outputTokens !== undefined) usage.outputTokens = outputTokens;
const reasoningOutputTokens = asCount(record.reasoningOutputTokens);
if (reasoningOutputTokens !== undefined) usage.reasoningOutputTokens = reasoningOutputTokens;
const toolUses = asCount(record.toolUses);
if (toolUses !== undefined) usage.toolUses = toolUses;
const durationMs = asCount(record.durationMs);
if (durationMs !== undefined) usage.durationMs = durationMs;
return usage;
}
/**
* Provider-specific usage merge (#4779 semantics, verbatim):
* - max-merge (Codex-style cumulative frames): field-wise maximum, idempotent
* under duplicate or late frames. Cumulative totals never shrink.
* - accumulate (Claude-style activation deltas): not needed at this layer —
* Claude's task_progress usage is itself cumulative per task, so the fold
* also max-merges. The distinction matters when v2 sums activations.
* Field-wise: a terminal payload carrying only totalTokens must not wipe a
* known breakdown.
*/
function mergeUsageMax(
current: SubagentUsage | null,
incoming: SubagentUsage | undefined,
): SubagentUsage | null {
if (!incoming) {
return current;
}
if (!current) {
return incoming;
}
const pick = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined ? b : b === undefined ? a : Math.max(a, b);
const merged: {
totalTokens: number;
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
reasoningOutputTokens?: number;
toolUses?: number;
durationMs?: number;
} = { totalTokens: Math.max(current.totalTokens, incoming.totalTokens) };
const inputTokens = pick(current.inputTokens, incoming.inputTokens);
if (inputTokens !== undefined) merged.inputTokens = inputTokens;
const cachedInputTokens = pick(current.cachedInputTokens, incoming.cachedInputTokens);
if (cachedInputTokens !== undefined) merged.cachedInputTokens = cachedInputTokens;
const outputTokens = pick(current.outputTokens, incoming.outputTokens);
if (outputTokens !== undefined) merged.outputTokens = outputTokens;
const reasoningOutputTokens = pick(current.reasoningOutputTokens, incoming.reasoningOutputTokens);
if (reasoningOutputTokens !== undefined) merged.reasoningOutputTokens = reasoningOutputTokens;
const toolUses = pick(current.toolUses, incoming.toolUses);
if (toolUses !== undefined) merged.toolUses = toolUses;
const durationMs = pick(current.durationMs, incoming.durationMs);
if (durationMs !== undefined) merged.durationMs = durationMs;
return merged;For Codex and Claude task data, the normalizer can roll up per-task totalTokens,
input/cache/output/reasoning fields, toolUses, and durationMs when typed usage
supplies them. These are task-local, provider-observed rollups. They are not invoice
records. Inference from the separate ingestion paths: they do not feed either
Chapter 20 lane; neither the live thread-context meter nor the transcript-scan
historical usage total consumes this task rollup.
Live liveness and plan progress are intentionally not durable
ThreadBackgroundLivenessService is an in-memory registry for sidebar status
labels. Agent work becomes working; monitor work becomes monitoring only when
no agent work is live. Inert plan-mode bookkeeping is excluded. The registry is
empty after restart until fresh task events arrive, and a dead session clears it.
Plan progress follows the same pattern. It stores a current in-progress or next pending step with completed/total counts in memory, clears when every step is complete, and clears when the turn settles or the session dies. It is a live annotation for working indicators, not a durable plan-execution ledger.
A durable thread can therefore remain visible after its liveness pill and plan-progress label disappear. That is not loss of the thread record; it avoids showing stale runtime work as live.
Quiet views select from the same evidence
Web chat keeps the parent narrative quiet: agent lifecycle rows collapse into a spawn-batch call to action that opens Agents. Direct agents group by spawn turn; workflow members group under their coordinator. The detailed per-agent state lives in the derived Agents roster rather than one parent-log row per task. Mobile has no matching Agents roster on this path, so it retains compact terminal/task signals per task identity in the work log. Both are presentations of thread activity, not separate stores.
Skills are provider inventory, not orchestrated tasks
Each provider-instance snapshot carries default-empty slashCommands and skills
arrays. Their contents remain driver-specific: Codex asks app-server for its native
skill inventory and adds T3’s feedback command; Claude combines discovered skills
with commands reported during SDK initialization; OpenCode flattens its inventory;
the inspected Cursor and Grok snapshots expose models and probe state without
skills or slash commands.
The composer then normalizes presentation, not execution, with surface-specific
menu policy. On web, enabled skills join / only when showSkillsInSlashMenu is
enabled, and a visible skill name suppresses the same-named provider slash command.
Mobile independently includes enabled skills alongside matching provider commands
and does not apply that web collision helper. Both $ paths search enabled skills;
choosing one inserts $skill provider-facing prompt syntax. That insertion does not
create a durable task, plan step, dependency, or scheduler assignment by itself.
One thread, three surface projections
Switch views to see what a surface may derive from durable thread activity without inventing a scheduler record.
Web · chat work log. Illustrative projection, not live data: a quiet parent narrative collapses agent lifecycle into a spawn-batch call to action.
Illustrative projection, not live data: a quiet parent narrative collapses agent lifecycle into a spawn-batch call to action.
- Parentthread.message-sentdurable thread narrative
- Spawn batchdirect agentsgrouped by spawn turn
- Spawn batchworkflow membersgrouped under the coordinator; CTA opens Agents
Static projection rules
- Web chat groups agent lifecycle into spawn-batch CTAs that open Agents: direct agents by spawn turn, workflow members by coordinator.
- Web Agents renders a derived roster, including task-local typed usage when supplied.
- Mobile retains compact terminal/task signals because this path has no equivalent Agents roster.
- Pin, snooze, and lifecycle organize the thread; they do not schedule provider tasks.
What the model does and does not promise
| State or view | Durable? | Source of truth | Appropriate claim |
|---|---|---|---|
| Thread, messages, activities, checkpoints, lifecycle overlays | yes | orchestration read model/projections | durable project work record |
| Provider task/subagent identity and status | activity-backed, provider-derived | normalized runtime events and client fold | observed state, subject to provider delivery and retention |
| Web Agents roster and rollups | derived | client fold or future v2 projection | current presentation of retained evidence |
| Background liveness / plan progress | no | in-memory server service | current server observation only |
| Snooze / pin / settled placement | yes | thread lifecycle fields | inbox/display organization, not execution scheduling |
| Composer skills / commands | no task state implied | provider inventory and composer UI | selectable invocation affordance |
A durable scheduler could add assignments, dependencies, retry rules, and reconciliation receipts. That is a future design, not an inference that the current activity fold already provides those guarantees.