# Files changed · PR \#23898 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23898/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): task_status reports how long a running delegated task has been silent](https://git.cafe/pingdotgg/t3code/pull/23898.md)

Version: 3

Head commit: 50acc3565a7462fb943ec9cb13863496330f8075

Base commit: e78297d89133eb69a375750974c88aa744dae7c7

Comparison base: ff31a2a62b6b56e877b3b6baa6c901f10d603060

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/mcp/OrchestratorMcpService.activity.test.ts

Status: modified; +306; −1

```diff
@@ -6,6 +6,8 @@
   ProjectId,
   ProviderDriverKind,
   ProviderInstanceId,
+  ProviderTurnId,
+  RunAttemptId,
   RunId,
   ScheduledTaskId,
   ThreadId,
@@ -14,7 +16,9 @@
 import * as Effect from "effect/Effect";
 import * as Layer from "effect/Layer";
 import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
+import { it as effectIt } from "@effect/vitest";
-import { expect, it } from "vite-plus/test";
+import { TestClock } from "effect/testing";
+import { describe, expect, it } from "vite-plus/test";
 
 import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
 import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
@@ -256,6 +260,8 @@
     ],
     subagents: [],
     providerThreads: [],
+    providerTurns: [],
+    turnItems: [],
     updatedAt: now,
   } as unknown as OrchestrationV2ThreadProjection;
 
@@ -538,3 +544,302 @@
     expect(stopped.code).toBe("parent_not_active");
   }).pipe(Effect.provide(layer), Effect.runPromise);
 });
+
+describe("taskStatus activity", () => {
+  const attemptId = RunAttemptId.make("attempt-mcp-child-2");
+  const supersededAttemptId = RunAttemptId.make("attempt-mcp-child-1");
+  const activeTurnId = ProviderTurnId.make("provider-turn-mcp-child-2");
+  const supersededTurnId = ProviderTurnId.make("provider-turn-mcp-child-1");
+  const lastEventAt = DateTime.makeUnsafe("2026-08-04T12:05:00.000Z");
+
+  const openItem = (
+    providerTurnId: ProviderTurnId | null,
+    overrides: { readonly status?: string; readonly runId?: RunId } = {},
+  ) => ({
+    id: `item-${providerTurnId ?? "app"}`,
+    type: "command_execution",
+    runId: overrides.runId ?? childRunId,
+    providerTurnId,
+    status: overrides.status ?? "running",
+    updatedAt: now,
+  });
+
+  const secondRunId = RunId.make("run-mcp-child-second");
+
+  const statusLayer = (input: {
+    readonly runStatus: "running" | "queued" | "completed";
+    readonly openItems?: ReadonlyArray<ReturnType<typeof openItem>>;
+    readonly activeAttemptId?: RunAttemptId | null;
+    readonly secondActiveRun?: boolean;
+    readonly requests?: Array<{
+      readonly fields: ReadonlyArray<string> | undefined;
+      readonly filter: unknown;
+    }>;
+  }) => {
+    const terminal = input.runStatus === "completed";
+    const parentProjection = {
+      thread: baseThread({
+        threadId: parentThreadId,
+        title: "Parent",
+        instanceId: parentInstanceId,
+        model: "gpt-5.4",
+      }),
+      runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })],
+      runtimeRequests: [],
+      messages: [],
+      contextTransfers: [],
+      subagents: [
+        {
+          id: taskId,
+          threadId: parentThreadId,
+          runId: activeRunId,
+          origin: "app_owned",
+          providerInstanceId: parentInstanceId,
+          childThreadId,
+          model: "gpt-5.4",
+          status: input.runStatus === "queued" ? "running" : input.runStatus,
+          result: terminal ? "Done." : null,
+          completionDelivery: terminal ? { state: "acknowledged" } : undefined,
+        },
+      ],
+      updatedAt: now,
+    } as unknown as OrchestrationV2ThreadProjection;
+    const childProjection = {
+      thread: {
+        ...baseThread({
+          threadId: childThreadId,
+          title: "Child",
+          instanceId: parentInstanceId,
+          model: "gpt-5.4",
+        }),
+        updatedAt: lastEventAt,
+      },
+      runs: [
+        {
+          ...makeRun({ id: childRunId, ordinal: 1, status: input.runStatus }),
+          activeAttemptId: input.activeAttemptId === undefined ? attemptId : input.activeAttemptId,
+        },
+        ...(input.secondActiveRun
+          ? [
+              {
+                ...makeRun({ id: secondRunId, ordinal: 2, status: "running" }),
+                activeAttemptId: null,
+              },
+            ]
+          : []),
+      ],
+      providerTurns: [
+        { id: supersededTurnId, runAttemptId: supersededAttemptId },
+        { id: activeTurnId, runAttemptId: attemptId },
+      ],
+      turnItems: input.openItems ?? [],
+      nodes: [],
+      runtimeRequests: [],
+      messages: [],
+      contextTransfers: [
+        {
+          type: "subagent_spawn",
+          sourceThreadId: parentThreadId,
+          targetThreadId: childThreadId,
+          targetRunId: childRunId,
+        },
+      ],
+      subagents: [],
+      providerThreads: [],
+      updatedAt: lastEventAt,
+    } as unknown as OrchestrationV2ThreadProjection;
+
+    return layerOrchestratorService.pipe(
+      Layer.provide(
+        Layer.mergeAll(
+          Layer.mock(ThreadManagementService.ThreadManagementService)({
+            getThreadRecords: (threadId, fields, filter) => {
+              if (threadId === parentThreadId) return Effect.succeed(parentProjection);
+              if (threadId !== childThreadId) return Effect.die(`unexpected thread ${threadId}`);
+              input.requests?.push({ fields, filter });
+              return Effect.succeed(childProjection);
+            },
+            delegatedTaskResultPending: () => Effect.succeed(false),
+          } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
+          ServerSettings.layerTest(),
+          Layer.mock(ProviderRegistry.ProviderRegistry)({
+            getProviders: Effect.succeed([]),
+          } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
+          Layer.mock(ProjectService.ProjectService)({}),
+          Layer.mock(SecretRequests.SecretRequests)({}),
+          Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
+          Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
+            list: () => Effect.succeed([]),
+          } satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
+          NodeCrypto.layer,
+        ),
+      ),
+    );
+  };
+
+  const readStatus = Effect.gen(function* () {
+    const service = yield* OrchestratorMcpService.OrchestratorMcpService;
+    return yield* service.taskStatus(makeScope(), taskId);
+  });
+
+  effectIt.effect("reports how long a running task has recorded no activity", () =>
+    Effect.gen(function* () {
+      yield* TestClock.setTime(DateTime.toEpochMillis(lastEventAt) + 12 * 60_000);
+      const stale = yield* readStatus;
+      expect(stale).toMatchObject({
+        status: "running",
+        lastActivityAt: "2026-08-04T12:05:00.000Z",
+        idleForMs: 12 * 60_000,
+        hasRunningItem: false,
+      });
+
+      yield* TestClock.adjust("3 minutes");
+      expect((yield* readStatus).idleForMs).toBe(15 * 60_000);
+    }).pipe(Effect.provide(statusLayer({ runStatus: "running" }))),
+  );
+
+  const hasRunningItem = (input: Parameters<typeof statusLayer>[0]) =>
+    readStatus.pipe(
+      Effect.map((result) => result.hasRunningItem),
+      Effect.provide(statusLayer(input)),
+    );
+
+  effectIt.effect("counts open items only from the run's active attempt", () =>
+    Effect.gen(function* () {
+      const running = { runStatus: "running" } as const;
+      expect(yield* hasRunningItem({ ...running, openItems: [openItem(supersededTurnId)] })).toBe(
+        false,
+      );
+      expect(yield* hasRunningItem({ ...running, openItems: [openItem(activeTurnId)] })).toBe(true);
+      // Workspace preparation runs before the provider turn exists.
+      expect(yield* hasRunningItem({ ...running, openItems: [openItem(null)] })).toBe(true);
+      const unknownTurn = ProviderTurnId.make("provider-turn-mcp-unknown");
+      expect(yield* hasRunningItem({ ...running, openItems: [openItem(unknownTurn)] })).toBe(true);
+      // Without an active attempt nothing is known to be superseded.
+      expect(
+        yield* hasRunningItem({
+          ...running,
+          activeAttemptId: null,
+          openItems: [openItem(supersededTurnId)],
+        }),
+      ).toBe(true);
+    }),
+  );
+
+  effectIt.effect("ignores settled items and items of runs the child does not have", () =>
+    Effect.gen(function* () {
+      expect(
+        yield* hasRunningItem({
+          runStatus: "running",
+          openItems: [
+            openItem(activeTurnId, { status: "completed" }),
+            openItem(null, { runId: RunId.make("run-mcp-missing") }),
+          ],
+        }),
+      ).toBe(false);
+    }),
+  );
+
+  effectIt.effect("counts an open item of another active run in the child", () =>
+    Effect.gen(function* () {
+      const requests: NonNullable<Parameters<typeof statusLayer>[0]["requests"]> = [];
+      expect(
+        yield* hasRunningItem({
+          runStatus: "running",
+          secondActiveRun: true,
+          openItems: [openItem(null, { runId: secondRunId })],
+          requests,
+        }),
+      ).toBe(true);
+      expect(
+        requests.find(
+          (request) => request.fields?.length === 1 && request.fields[0] === "turnItems",
+        )?.filter,
+      ).toMatchObject({ turnItemRunIds: [childRunId, secondRunId] });
+    }),
+  );
+
+  effectIt.effect("loads open items and provider turns only when they are needed", () =>
+    Effect.gen(function* () {
+      type Requests = NonNullable<Parameters<typeof statusLayer>[0]["requests"]>;
+      const activityQueries = (requests: Requests) =>
+        requests.filter(
+          (request) =>
+            request.fields?.length === 1 &&
+            (request.fields[0] === "turnItems" || request.fields[0] === "providerTurns"),
+        );
+
+      const appItem: Requests = [];
+      yield* hasRunningItem({
+        runStatus: "running",
+        openItems: [openItem(null)],
+        requests: appItem,
+      });
+      expect(activityQueries(appItem)).toEqual([
+        {
+          fields: ["turnItems"],
+          filter: {
+            turnItemRunIds: [childRunId],
+            turnItemStatuses: ["pending", "running", "waiting"],
+          },
+        },
+      ]);
+      expect(appItem.some((request) => request.fields?.includes("providerTurns"))).toBe(false);
+
+      const providerItem: Requests = [];
+      yield* hasRunningItem({
+        runStatus: "running",
+        openItems: [openItem(activeTurnId)],
+        requests: providerItem,
+      });
+      expect(activityQueries(providerItem).map((request) => request.fields)).toEqual([
+        ["turnItems"],
+        ["providerTurns"],
+      ]);
+
+      for (const [runStatus, expected] of [
+        ["queued", "queued"],
+        ["completed", "completed"],
+      ] as const) {
+        const requests: Requests = [];
+        const result = yield* readStatus.pipe(
+          Effect.provide(statusLayer({ runStatus, openItems: [openItem(activeTurnId)], requests })),
+        );
+        expect(result.status).toBe(expected);
+        expect(activityQueries(requests)).toEqual([]);
+      }
+
+      // A terminal task whose child still has other active work reports no activity.
+      const laterWork: Requests = [];
+      const terminal = yield* readStatus.pipe(
+        Effect.provide(
+          statusLayer({
+            runStatus: "completed",
+            secondActiveRun: true,
+            openItems: [openItem(null, { runId: secondRunId })],
+            requests: laterWork,
+          }),
+        ),
+      );
+      expect(terminal.status).toBe("completed");
+      expect(terminal.hasRunningItem).toBe(false);
+      expect(activityQueries(laterWork)).toEqual([]);
+    }),
+  );
+
+  effectIt.effect("omits activity for a terminal task", () =>
+    Effect.gen(function* () {
+      const finished = yield* readStatus.pipe(
+        Effect.provide(
+          statusLayer({ runStatus: "completed", openItems: [openItem(activeTurnId)] }),
+        ),
+      );
+      expect(finished).toMatchObject({
+        status: "completed",
+        lastActivityAt: null,
+        idleForMs: null,
+        hasRunningItem: false,
+      });
+    }),
+  );
+});
```

## apps/server/src/mcp/OrchestratorMcpService.test.ts

Status: modified; +17; −9

```diff
@@ -56,6 +56,8 @@
 import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
 
 const decodeTarget = Schema.decodeUnknownEffect(OrchestratorMcpTarget);
+
+const childUpdatedAt = DateTime.makeUnsafe("2026-10-03T10:00:00Z");
 
 const layerOrchestratorService = OrchestratorMcpService.layer.pipe(
   Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
@@ -89,7 +91,8 @@
         ],
       } as unknown as OrchestrationV2ThreadProjection;
       const childProjection = {
+        thread: { id: childThreadId, updatedAt: childUpdatedAt },
+        turnItems: [],
-        thread: { id: childThreadId },
         runs: [
           {
             id: childRunId,
@@ -223,7 +226,7 @@
         ],
       } as unknown as OrchestrationV2ThreadProjection;
       const childProjection = {
+        thread: { id: childThreadId, updatedAt: childUpdatedAt },
-        thread: { id: childThreadId },
         runs: [
           {
             id: RunId.make("run:mcp-restart-child"),
@@ -316,7 +319,8 @@
         ],
       } as unknown as OrchestrationV2ThreadProjection;
       const childProjection = {
+        thread: { id: childThreadId, updatedAt: childUpdatedAt },
+        turnItems: [],
-        thread: { id: childThreadId },
         runs: [],
         contextTransfers: [],
         messages: [],
@@ -400,7 +404,7 @@
           }) as unknown as OrchestrationV2ThreadProjection;
         // A finished run whose binding to the follow-up was lost (#19002).
         const childProjection = {
-          thread: { id: childThreadId },
+          thread: { id: childThreadId, updatedAt: childUpdatedAt },
           runs:
             childState === "no runs"
               ? []
@@ -503,7 +507,8 @@
         ],
       } as unknown as OrchestrationV2ThreadProjection;
       const childProjection = {
-        thread: { id: childThreadId },
+        thread: { id: childThreadId, updatedAt: childUpdatedAt },
+        turnItems: [],
         runs: [{ id: childRunId, status: "running" }],
         contextTransfers: [],
         messages: [],
@@ -583,7 +588,8 @@
         ],
       } as unknown as OrchestrationV2ThreadProjection;
       const childProjection = {
+        thread: { id: childThreadId, updatedAt: childUpdatedAt },
+        turnItems: [],
-        thread: { id: childThreadId },
         runs: [{ id: childRunId, status: "running" }],
         contextTransfers: [],
         messages: [],
@@ -669,7 +675,8 @@
         ],
       } as unknown as OrchestrationV2ThreadProjection;
       const childProjection = {
+        thread: { id: childThreadId, updatedAt: childUpdatedAt },
+        turnItems: [],
-        thread: { id: childThreadId },
         runs: [{ id: childRunId, status: "running" }],
         contextTransfers: [],
         messages: [],
@@ -764,7 +771,8 @@
         [
           childThreadId,
           {
+            thread: { id: childThreadId, updatedAt: childUpdatedAt },
-            thread: { id: childThreadId },
+            turnItems: [],
             runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }],
             contextTransfers: [],
             messages: [],
@@ -992,7 +1000,7 @@
     }) as unknown as OrchestrationV2ThreadProjection;
 
   const childProjection = {
+    thread: { id: childThreadId, updatedAt: childUpdatedAt },
-    thread: { id: childThreadId },
     runs: [],
     contextTransfers: [],
     messages: [],
```

## apps/server/src/mcp/OrchestratorMcpService.ts

Status: modified; +51; −0

```diff
@@ -545,6 +545,29 @@
   return approval;
 }
 
+const IN_PROGRESS_ITEM_STATUSES = ["pending", "running", "waiting"] as const;
+const isInProgressItemStatus = (status: OrchestrationV2TurnItem["status"]) =>
+  (IN_PROGRESS_ITEM_STATUSES as ReadonlyArray<string>).includes(status);
+
+/**
+ * A restart supersedes an attempt without closing its items, so only active-attempt items count.
+ * Without a known active attempt or turn, nothing is known to be superseded.
+ */
+function hasRunningAttemptItem(
+  runs: ReadonlyArray<OrchestrationV2Run>,
+  providerTurns: OrchestrationV2ThreadProjection["providerTurns"],
+  items: ReadonlyArray<Pick<OrchestrationV2TurnItem, "runId" | "providerTurnId" | "status">>,
+): boolean {
+  return items.some((item) => {
+    const run = runs.find((candidate) => candidate.id === item.runId);
+    if (run === undefined || !ThreadManagementService.isActiveRun(run)) return false;
+    if (!isInProgressItemStatus(item.status)) return false;
+    if (item.providerTurnId === null) return true;
+    const attemptId = providerTurns.find((turn) => turn.id === item.providerTurnId)?.runAttemptId;
+    return attemptId == null || run.activeAttemptId === null || attemptId === run.activeAttemptId;
+  });
+}
+
 function directAppOwnedChildTasks(
   parent: Pick<OrchestrationV2ThreadProjection, "thread" | "subagents">,
   target: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
@@ -1616,6 +1639,28 @@
         instanceId: task.providerInstanceId,
         model: task.model,
       };
+    // thread.updatedAt is the last recorded change to the child thread (e.g. provider output,
+    // run/attempt changes, the parent's sends), so a silent provider leaves it unchanged.
+    const tracksActivity = status === "running" || status === "waiting";
+    const activeRunIds = childControls.runs
+      .filter(ThreadManagementService.isActiveRun)
+      .map((run) => run.id);
+    const openItems =
+      tracksActivity && activeRunIds.length > 0
+        ? (yield* threadManagement
+            .getThreadRecords(task.childThreadId, ["turnItems"], {
+              turnItemRunIds: activeRunIds,
+              turnItemStatuses: IN_PROGRESS_ITEM_STATUSES,
+            })
+            .pipe(Effect.mapError(threadManagementFailure))).turnItems
+        : [];
+    const providerTurns = openItems.some((item) => item.providerTurnId !== null)
+      ? (yield* threadManagement
+          .getThreadRecords(task.childThreadId, ["providerTurns"])
+          .pipe(Effect.mapError(threadManagementFailure))).providerTurns
+      : [];
+    const lastActivityAt = tracksActivity ? childControls.thread.updatedAt : null;
+    const nowMs = yield* Clock.currentTimeMillis;
     const response = {
       taskId: task.id,
       childThreadId: task.childThreadId,
@@ -1650,6 +1695,12 @@
           ? null
           : pendingTaskApproval(childControls, task.id, childRun),
       waitTimedOut,
+      lastActivityAt: lastActivityAt === null ? null : DateTime.formatIso(lastActivityAt),
+      idleForMs:
+        lastActivityAt === null
+          ? null
+          : Math.max(0, nowMs - DateTime.toEpochMillis(lastActivityAt)),
+      hasRunningItem: hasRunningAttemptItem(childControls.runs, providerTurns, openItems),
     } satisfies OrchestratorMcpDelegateTaskResult;
     if (
       acknowledgeTerminal &&
```

## apps/server/src/mcp/toolkits/orchestrator/tools.ts

Status: modified; +1; −1

```diff
@@ -80,7 +80,7 @@
 
 const TaskStatusTool = Tool.make("task_status", {
   description:
-    "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. Turns held in a stopped queue wait for the user and do not count. latestTerminal* provides later non-monitor turn results; latestTerminalSummary and latestTerminalResultContextTransferId are independently null when they exactly repeat summary and resultContextTransferId, respectively. pendingApproval identifies the oldest pending live approval for this task's own unfinished work. Read the fresh full request in childThreadId before responding: the record can outlive its provider callback and grants no approval authority. Decisions remain with the child's existing approval holder. Reading a terminal result acknowledges its automatic parent delivery.",
+    "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. Turns held in a stopped queue wait for the user and do not count. latestTerminal* provides later non-monitor turn results; latestTerminalSummary and latestTerminalResultContextTransferId are independently null when they exactly repeat summary and resultContextTransferId, respectively. pendingApproval identifies the oldest pending live approval for this task's own unfinished work. Read the fresh full request in childThreadId before responding: the record can outlive its provider callback and grants no approval authority. Decisions remain with the child's existing approval holder. While the task runs, idleForMs is the time since the child last recorded activity; it is informational. A long idleForMs with workState working, hasRunningItem false, and no pending question or approval suggests a stalled provider: restart it with t3_thread_send mode=restart on childThreadId, or stop it with task_cancel. Reading a terminal result acknowledges its automatic parent delivery.",
   parameters: OrchestratorMcpTaskStatusInput,
   success: OrchestratorMcpDelegateTaskResult,
   failure: OrchestratorMcpFailure,
```

## apps/web/src/components/ChatView.tsx

Status: modified; +13; −0

```diff
@@ -1,5 +1,6 @@
 import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays";
 import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer";
+import { deriveActiveTurnLastActivityAt } from "./chat/TimelineWorkGroupRows.logic";
 import {
   deriveAvailableRevertTurnCounts,
   deriveThreadPendingBackgroundTasks,
@@ -1415,6 +1416,15 @@
   const activeWorkStartedAt =
     deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ??
     runlessWorkStartedAt;
+  const activeTurnLastActivityAt =
+    serverProjection === null || activeActivityRun === null || !isWorking
+      ? null
+      : deriveActiveTurnLastActivityAt({
+          projection: serverProjection,
+          runId: activeActivityRun.runId,
+          workStartedAt: activeWorkStartedAt,
+          items: serverVisibleTurnItems,
+        });
   const activeRunPreparing = activeActivityRun?.status === "preparing";
   const sendQueuesBehindSetup =
     isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting");
@@ -3282,6 +3292,9 @@
                     activeThreadShell?.hasPendingUserInput === true)
                 }
                 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
+                activeTurnLastActivityAt={
+                  paintOnlyDisplayedTimeline ? null : activeTurnLastActivityAt
+                }
                 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}
                 onCancelWorktreeSetup={onCancelWorktreeSetupStable}
                 {...(paintOnlyDisplayedTimeline
```

## apps/web/src/components/chat/MessagesTimeline.tsx

Status: modified; +3; −0

```diff
@@ -184,6 +184,7 @@
   runlessWorkActive?: boolean;
   activeTurnInProgress: boolean;
   activeTurnStartedAt?: string | null;
+  activeTurnLastActivityAt?: string | null;
   worktreeSetup?: WorktreeSetupSnapshot | null;
   onCancelWorktreeSetup?: () => void;
   retryableWorkspacePreparationRunIds?: ReadonlySet<RunId>;
@@ -290,6 +291,7 @@
   runlessWorkActive = false,
   activeTurnInProgress,
   activeTurnStartedAt = null,
+  activeTurnLastActivityAt = null,
   worktreeSetup = null,
   footer = null,
   onCancelWorktreeSetup,
@@ -948,6 +950,7 @@
     backgroundWorktreeSetup,
     activeTurnInProgress,
     awaitingUser,
+    activeTurnLastActivityAt,
     isPreparingWorktree,
     latestRunId: latestRun?.runId ?? null,
   };
```

## apps/web/src/components/chat/TimelineWorkGroupRows.logic.test.ts

Status: added; +110; −0

```diff
@@ -1,0 +1,110 @@
+import { ProviderTurnId, RunAttemptId, RunId } from "@t3tools/contracts";
+import type { ThreadStructureVisibleTurnItem } from "@t3tools/client-runtime/state/models";
+import * as DateTime from "effect/DateTime";
+import { describe, expect, it } from "vite-plus/test";
+
+import { deriveActiveTurnLastActivityAt, formatIdleHint } from "./TimelineWorkGroupRows.logic";
+
+const runId = RunId.make("run-1");
+const oldAttemptId = RunAttemptId.make("attempt-1");
+const attemptId = RunAttemptId.make("attempt-2");
+const oldTurnId = ProviderTurnId.make("turn-1");
+const turnId = ProviderTurnId.make("turn-2");
+const at = (time: string) => DateTime.makeUnsafe(`2026-10-22T${time}.000Z`);
+
+const projection = (attemptStartedAt: string) => ({
+  runs: [{ id: runId, activeAttemptId: attemptId }],
+  attempts: [
+    { id: oldAttemptId, startedAt: at("12:00:00") },
+    { id: attemptId, startedAt: at(attemptStartedAt) },
+  ],
+  providerTurns: [
+    { id: oldTurnId, runAttemptId: oldAttemptId },
+    { id: turnId, runAttemptId: attemptId },
+  ],
+});
+
+const item = (input: {
+  readonly status: string;
+  readonly updatedAt: string;
+  readonly providerTurnId?: ProviderTurnId;
+  readonly runId?: RunId;
+}) =>
+  ({
+    item: {
+      runId: input.runId ?? runId,
+      providerTurnId: input.providerTurnId ?? turnId,
+      status: input.status,
+      startedAt: at("12:00:00"),
+      completedAt: input.status === "completed" ? at(input.updatedAt) : null,
+      updatedAt: at(input.updatedAt),
+    },
+  }) as unknown as ThreadStructureVisibleTurnItem;
+
+const derive = (
+  items: ReadonlyArray<ThreadStructureVisibleTurnItem>,
+  attemptStartedAt = "12:00:00",
+) =>
+  deriveActiveTurnLastActivityAt({
+    projection: projection(attemptStartedAt) as never,
+    runId,
+    workStartedAt: "2026-10-22T12:00:00.000Z",
+    items,
+  });
+
+describe("deriveActiveTurnLastActivityAt", () => {
+  it("uses the latest item activity of the active run", () => {
+    expect(
+      derive([
+        item({ status: "completed", updatedAt: "12:04:00" }),
+        item({ status: "completed", updatedAt: "12:09:00", runId: RunId.make("run-other") }),
+        item({ status: "running", updatedAt: "12:09:00", runId: RunId.make("run-other") }),
+      ]),
+    ).toBe("2026-10-22T12:04:00.000Z");
+  });
+
+  it("is null while an item of the current attempt is in progress", () => {
+    expect(derive([item({ status: "running", updatedAt: "12:01:00" })])).toBeNull();
+  });
+
+  it("starts over from a restart and ignores the superseded attempt's open items", () => {
+    expect(
+      derive(
+        [
+          item({ status: "completed", updatedAt: "12:03:00" }),
+          item({ status: "running", updatedAt: "12:04:00", providerTurnId: oldTurnId }),
+        ],
+        "12:05:00",
+      ),
+    ).toBe("2026-10-22T12:05:00.000Z");
+  });
+});
+
+describe("deriveActiveTurnLastActivityAt without a baseline", () => {
+  it("is null with no attempt, work start, or item activity", () => {
+    expect(
+      deriveActiveTurnLastActivityAt({
+        projection: { runs: [], attempts: [], providerTurns: [] } as never,
+        runId,
+        workStartedAt: null,
+        items: [],
+      }),
+    ).toBeNull();
+  });
+});
+
+describe("formatIdleHint", () => {
+  const lastActivityAt = "2026-10-22T12:00:00.000Z";
+  const after = (ms: number) => Date.parse(lastActivityAt) + ms;
+
+  it("stays quiet below three minutes", () => {
+    expect(formatIdleHint(lastActivityAt, after(2 * 60_000 + 59_000))).toBeNull();
+    expect(formatIdleHint(null, after(60 * 60_000))).toBeNull();
+  });
+
+  it("reports whole minutes from the threshold on", () => {
+    expect(formatIdleHint(lastActivityAt, after(3 * 60_000))).toBe("No activity for 3m");
+    expect(formatIdleHint(lastActivityAt, after(15 * 60_000 + 35_000))).toBe("No activity for 15m");
+    expect(formatIdleHint(lastActivityAt, after(75 * 60_000))).toBe("No activity for 1h 15m");
+  });
+});
```

## apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts

Status: modified; +58; −0

```diff
@@ -1,4 +1,10 @@
+import type { RunId } from "@t3tools/contracts";
+import type {
+  ThreadStructure,
+  ThreadStructureVisibleTurnItem,
+} from "@t3tools/client-runtime/state/models";
 import { formatDuration } from "@t3tools/shared/orchestrationTiming";
+import * as DateTime from "effect/DateTime";
 
 function formatWorkingTimer(startIso: string, endIso: string): string | null {
   const startedAtMs = Date.parse(startIso);
@@ -18,3 +24,55 @@
 export function formatWorkingTimerNow(startIso: string): string {
   return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
 }
+
+const IN_PROGRESS_ITEM_STATUSES: ReadonlySet<string> = new Set(["pending", "running", "waiting"]);
+
+/**
+ * When the run's current attempt last showed activity, or null while one of its items is still
+ * in progress, since a long command can be silent without being stuck. A restart supersedes the
+ * attempt without closing its items, so those neither count as in progress nor move the baseline
+ * past the new attempt's start.
+ */
+export function deriveActiveTurnLastActivityAt(input: {
+  readonly projection: Pick<ThreadStructure, "runs" | "attempts" | "providerTurns">;
+  readonly runId: RunId;
+  readonly workStartedAt: string | null;
+  readonly items: ReadonlyArray<ThreadStructureVisibleTurnItem>;
+}): string | null {
+  const run = input.projection.runs.find((candidate) => candidate.id === input.runId);
+  const attempt = input.projection.attempts.find(
+    (candidate) => candidate.id === run?.activeAttemptId,
+  );
+  const isSuperseded = (providerTurnId: string | null) => {
+    if (providerTurnId === null || attempt === undefined) return false;
+    const attemptId = input.projection.providerTurns.find(
+      (turn) => turn.id === providerTurnId,
+    )?.runAttemptId;
+    return attemptId != null && attemptId !== attempt.id;
+  };
+  let latestMs = input.workStartedAt === null ? Number.NaN : Date.parse(input.workStartedAt);
+  const observe = (at: DateTime.Utc | null | undefined) => {
+    if (at == null) return;
+    const atMs = DateTime.toEpochMillis(at);
+    if (!(atMs <= latestMs)) latestMs = atMs;
+  };
+  observe(attempt?.startedAt);
+  for (const { item } of input.items) {
+    if (item.runId !== input.runId || isSuperseded(item.providerTurnId)) continue;
+    if (IN_PROGRESS_ITEM_STATUSES.has(item.status)) return null;
+    observe(item.startedAt);
+    observe(item.completedAt);
+    // The structure drops updatedAt from streamed text rows; their completion marks the activity.
+    if ("updatedAt" in item) observe(item.updatedAt);
+  }
+  return Number.isFinite(latestMs) ? new Date(latestMs).toISOString() : null;
+}
+
+const IDLE_HINT_AFTER_MS = 3 * 60_000;
+
+export function formatIdleHint(lastActivityAt: string | null, nowMs: number): string | null {
+  if (lastActivityAt === null) return null;
+  const idleMs = nowMs - Date.parse(lastActivityAt);
+  if (!(idleMs >= IDLE_HINT_AFTER_MS)) return null;
+  return `No activity for ${formatDuration(Math.floor(idleMs / 60_000) * 60_000)}`;
+}
```

## apps/web/src/components/chat/TimelineWorkGroupRows.tsx

Status: modified; +24; −3

```diff
@@ -55,7 +55,7 @@
   toolGroupSummaryIconName,
   workEntryIconName,
 } from "./TimelineWorkEntryRow.logic";
-import { formatWorkingTimerNow } from "./TimelineWorkGroupRows.logic";
+import { formatIdleHint, formatWorkingTimerNow } from "./TimelineWorkGroupRows.logic";
 
 export const WorkGroupSection = memo(function WorkGroupSection({
   anchorKey,
@@ -130,6 +130,19 @@
   );
 }
 
+function IdleHint({ lastActivityAt }: { lastActivityAt: string }) {
+  const [nowMs, setNowMs] = useState(Date.now);
+
+  useEffect(() => {
+    // Minute resolution, so a coarse tick is enough to cross the threshold on time.
+    const id = setInterval(() => setNowMs(Date.now()), 15_000);
+    return () => clearInterval(id);
+  }, []);
+
+  const hint = formatIdleHint(lastActivityAt, nowMs);
+  return hint === null ? null : <span className="-ml-1 min-w-0 truncate">· {hint}</span>;
+}
+
 const compactWorkEntryHeight = 24;
 
 function ExpandedWorkGroupEntries({
@@ -284,8 +297,13 @@
 const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
 
 export function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
+  const {
+    isCompacting,
+    isPreparingWorktree,
+    backgroundWorktreeSetup,
+    awaitingUser,
+    activeTurnLastActivityAt,
-  const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup } =
+  } = use(TimelineRowActivityCtx);
-    use(TimelineRowActivityCtx);
   const shimmer = isPreparingWorktree || isCompacting;
   const label = isPreparingWorktree ? (
     "Setting up worktree…"
@@ -308,6 +326,9 @@
           {label}
           {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
         </span>
+        {row.createdAt && !shimmer && !awaitingUser && activeTurnLastActivityAt !== null ? (
+          <IdleHint lastActivityAt={activeTurnLastActivityAt} />
+        ) : null}
         {backgroundWorktreeSetup ? (
           <BackgroundWorktreeSetupChip snapshot={backgroundWorktreeSetup} />
         ) : null}
```

## apps/web/src/components/chat/timelineRowContext.ts

Status: modified; +2; −0

```diff
@@ -72,6 +72,8 @@
   isRevertingCheckpoint: boolean;
   activeTurnInProgress: boolean;
   awaitingUser: boolean;
+  /** Null while an item is in progress or no turn is active. */
+  activeTurnLastActivityAt: string | null;
   isPreparingWorktree: boolean;
   latestRunId: RunId | null;
   backgroundWorktreeSetup: WorktreeSetupSnapshot | null;
```

## docs/orchestration-v2/orchestrator-mcp-server.md

Status: modified; +9; −0

````diff
@@ -301,6 +301,9 @@
     runId: string;
   } | null;
   waitTimedOut: boolean;
+  lastActivityAt: string | null;
+  idleForMs: number | null;
+  hasRunningItem: boolean;
 };
 ```
 
@@ -315,6 +318,12 @@
 `latestTerminal*` exposes later executed, non-monitor results without replacing
 the published task result.
 
+While the task is running or waiting, `lastActivityAt` and `idleForMs` give the
+time of the last recorded change to the child thread (e.g. provider output,
+run/attempt changes, the parent's sends), and `hasRunningItem` reports an open
+item in an active run's current attempt. A provider that stops emitting events
+leaves the task running with growing `idleForMs`; nothing times it out.
+
 ### `task_cancel`
 
 Stops the child thread with the internal `thread.stop` command, then stops every
````

## packages/contracts/src/orchestratorMcp.test.ts

Status: modified; +3; −0

```diff
@@ -42,6 +42,9 @@
       hasPendingQuestion: false,
       pendingApproval: null,
       waitTimedOut: false,
+      lastActivityAt: null,
+      idleForMs: null,
+      hasRunningItem: false,
     });
 
     expect(request.target?.providerInstanceId).toBe("claudeAgent");
```

## packages/contracts/src/orchestratorMcp.ts

Status: modified; +11; −0

```diff
@@ -227,6 +227,17 @@
     description:
       "True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.",
   }),
+  lastActivityAt: Schema.NullOr(IsoDateTime).annotate({
+    description:
+      "Time of the last recorded change to the child thread (e.g. provider output, run/attempt changes, the parent's sends), while the task is running or waiting. Null when queued or terminal.",
+  }),
+  idleForMs: Schema.NullOr(NonNegativeInt).annotate({
+    description: "Milliseconds since lastActivityAt at read time. Null when lastActivityAt is.",
+  }),
+  hasRunningItem: Schema.Boolean.annotate({
+    description:
+      "An item of the child's active attempt is still in progress, such as a command, which can run silently for a long time.",
+  }),
 });
 export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type;
 
```
