[Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

Description

@radroid

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I am describing a concrete problem or use case, not just a vague idea.

Area

apps/server

Problem or use case

The claim this issue exists to correct

An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

  • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
  • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
  • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
  • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
  • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
  • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

Why nothing in T3 blocks it

  • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
  • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
  • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
  • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

The three real gaps

1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

Claude-only, and that is a product problem

CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

Proposed solution

Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


Phase 0 — settle it empirically before writing code

Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

Experiment A — does a cron-fired turn actually reach T3's runtime?
Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


Phase 1 — observe, surface, and bound (the shippable slice)

1a. Subscribe the Stop hook and read session_crons

options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

  • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
  • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
  • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

1b. Surface it: GET /api/t3x/loop-crons

Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

1c. Web: one line in the existing fork-owned overlay

Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

  • Wakes 03:40 · self-paced when session_crons is non-empty
  • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
  • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

1d. Bound it

Before Phase 1 ships, close the "model arms itself unattended" hole:

  • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
  • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
  • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

Should T3 hand models its own tools at all? Yes. It already does.

mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

Reject createSdkMcpServer / tool()

They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

  1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
  2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
  3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

The starter set (four tools, one new toolkit)

New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

ToolContractWhy
wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

Phase 2 seam cost is real and is why it is Phase 2

The capability gate is not extensible without touching three upstream files:

  • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
  • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
  • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


How this composes with Loop Watch (#38)

They are complementary, and the layering is clean because of one property worth stating precisely.

#38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

Precedence when both are armed

There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

#15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

One correction #38 needs regardless

#38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


Files this touches

New, fork-owned (zero conflict surface):

Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

Why this matters

It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

Smallest useful scope

Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

Concretely, one genuinely shippable first pass:

  1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
  2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
  3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
  4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
  5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
  6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

Explicitly out of scope for v1:

Alternatives considered

1. createSdkMcpServer + tool() for in-process tools — rejected.
Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

2. Do nothing — /loop already works, just tell users to type it.
Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

3. Poll CronList from T3 instead of subscribing the Stop hook.
Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

Risks or tradeoffs

Seam cost (per docs/t3x/SEAMS.md)

The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

FilePhasefork ΔchurnriskStatus
apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
apps/server/src/mcp/McpHttpServer.ts2~6636New row
apps/server/src/mcp/McpSessionRegistry.ts2~177New row
apps/server/src/mcp/McpInvocationContext.ts2~236New row
apps/server/src/t3x/index.ts100Fork-owned aggregator
apps/server/src/server.ts029Untouched (existing row)

Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

Correctness and product risks

The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

Examples or references

Related issues — this is adjacent to, not a duplicate of, several open items

A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

Upstream — scheduling (the closest cluster):

Upstream — app-native tools:

Fork:

Evidence index

Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

  • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
  • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
  • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
  • sdk.d.ts:4204-4222SessionCronSummary
  • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
  • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
  • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

  • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
  • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
  • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
  • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
  • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
  • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
  • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
  • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
  • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

  • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
  • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
  • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
  • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
  • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
  • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
  • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
  • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
  • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
  • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
  • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
  • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
  • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
  • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


Duplicate search performed before filing

Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

Contribution

  • I would be open to helping implement this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

      Description

      @radroid

      Before submitting

      • I searched existing issues and did not find a duplicate.
      • I am describing a concrete problem or use case, not just a vague idea.

      Area

      apps/server

      Problem or use case

      The claim this issue exists to correct

      An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

      Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

      • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
      • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
      • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
      • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
      • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
      • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

      Why nothing in T3 blocks it

      • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
      • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
      • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
      • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

      So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

      The three real gaps

      1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

      2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

      3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

      Claude-only, and that is a product problem

      CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

      Proposed solution

      Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


      Phase 0 — settle it empirically before writing code

      Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

      Experiment A — does a cron-fired turn actually reach T3's runtime?
      Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

      Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

      Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

      Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

      If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


      Phase 1 — observe, surface, and bound (the shippable slice)

      1a. Subscribe the Stop hook and read session_crons

      options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

      sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

      This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

      Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

      ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

      Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

      Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

      • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
      • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
      • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

      Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

      1b. Surface it: GET /api/t3x/loop-crons

      Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

      Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

      1c. Web: one line in the existing fork-owned overlay

      Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

      • Wakes 03:40 · self-paced when session_crons is non-empty
      • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
      • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

      Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

      1d. Bound it

      Before Phase 1 ships, close the "model arms itself unattended" hole:

      • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
      • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
      • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

      Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

      Should T3 hand models its own tools at all? Yes. It already does.

      mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

      Reject createSdkMcpServer / tool()

      They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

      1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
      2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
      3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

      The starter set (four tools, one new toolkit)

      New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

      ToolContractWhy
      wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
      loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
      loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
      thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

      Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

      Phase 2 seam cost is real and is why it is Phase 2

      The capability gate is not extensible without touching three upstream files:

      • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
      • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
      • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

      There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


      How this composes with Loop Watch (#38)

      They are complementary, and the layering is clean because of one property worth stating precisely.

      #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

      A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

      While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

      That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

      Precedence when both are armed

      There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

      Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

      #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

      This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

      Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

      One correction #38 needs regardless

      #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


      Files this touches

      New, fork-owned (zero conflict surface):

      Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

      Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

      Why this matters

      It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

      It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

      It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

      It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

      The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

      Smallest useful scope

      Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

      Concretely, one genuinely shippable first pass:

      1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
      2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
      3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
      4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
      5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
      6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

      Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

      Explicitly out of scope for v1:

      Alternatives considered

      1. createSdkMcpServer + tool() for in-process tools — rejected.
      Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

      2. Do nothing — /loop already works, just tell users to type it.
      Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

      3. Poll CronList from T3 instead of subscribing the Stop hook.
      Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

      4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
      Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

      5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
      Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

      6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
      The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

      Risks or tradeoffs

      Seam cost (per docs/t3x/SEAMS.md)

      The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

      Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

      FilePhasefork ΔchurnriskStatus
      apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
      apps/server/src/mcp/McpHttpServer.ts2~6636New row
      apps/server/src/mcp/McpSessionRegistry.ts2~177New row
      apps/server/src/mcp/McpInvocationContext.ts2~236New row
      apps/server/src/t3x/index.ts100Fork-owned aggregator
      apps/server/src/server.ts029Untouched (existing row)

      Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

      Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

      Correctness and product risks

      The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

      A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

      CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

      The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

      Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

      Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

      Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

      Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

      Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

      Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

      Examples or references

      Related issues — this is adjacent to, not a duplicate of, several open items

      A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

      Upstream — scheduling (the closest cluster):

      Upstream — app-native tools:

      Fork:

      Evidence index

      Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

      • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
      • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
      • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
      • sdk.d.ts:4204-4222SessionCronSummary
      • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
      • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
      • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

      Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

      • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
      • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
      • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
      • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
      • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
      • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
      • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
      • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
      • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

      Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

      T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

      • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
      • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
      • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
      • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
      • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
      • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
      • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
      • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
      • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
      • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
      • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
      • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
      • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
      • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

      Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
      ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


      Duplicate search performed before filing

      Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

      Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

      Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

      Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

      Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

      Contribution

      • I would be open to helping implement this.

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        enhancementNew feature or request

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

          Description

          @radroid

          Before submitting

          • I searched existing issues and did not find a duplicate.
          • I am describing a concrete problem or use case, not just a vague idea.

          Area

          apps/server

          Problem or use case

          The claim this issue exists to correct

          An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

          Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

          • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
          • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
          • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
          • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
          • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
          • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

          Why nothing in T3 blocks it

          • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
          • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
          • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
          • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

          So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

          The three real gaps

          1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

          2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

          3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

          Claude-only, and that is a product problem

          CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

          Proposed solution

          Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


          Phase 0 — settle it empirically before writing code

          Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

          Experiment A — does a cron-fired turn actually reach T3's runtime?
          Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

          Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

          Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

          Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

          If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


          Phase 1 — observe, surface, and bound (the shippable slice)

          1a. Subscribe the Stop hook and read session_crons

          options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

          sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

          This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

          Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

          ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

          Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

          Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

          • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
          • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
          • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

          Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

          1b. Surface it: GET /api/t3x/loop-crons

          Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

          Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

          1c. Web: one line in the existing fork-owned overlay

          Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

          • Wakes 03:40 · self-paced when session_crons is non-empty
          • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
          • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

          Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

          1d. Bound it

          Before Phase 1 ships, close the "model arms itself unattended" hole:

          • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
          • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
          • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

          Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

          Should T3 hand models its own tools at all? Yes. It already does.

          mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

          Reject createSdkMcpServer / tool()

          They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

          1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
          2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
          3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

          The starter set (four tools, one new toolkit)

          New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

          ToolContractWhy
          wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
          loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
          loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
          thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

          Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

          Phase 2 seam cost is real and is why it is Phase 2

          The capability gate is not extensible without touching three upstream files:

          • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
          • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
          • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

          There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


          How this composes with Loop Watch (#38)

          They are complementary, and the layering is clean because of one property worth stating precisely.

          #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

          A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

          While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

          That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

          Precedence when both are armed

          There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

          Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

          #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

          This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

          Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

          One correction #38 needs regardless

          #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


          Files this touches

          New, fork-owned (zero conflict surface):

          Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

          Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

          Why this matters

          It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

          It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

          It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

          It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

          The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

          Smallest useful scope

          Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

          Concretely, one genuinely shippable first pass:

          1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
          2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
          3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
          4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
          5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
          6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

          Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

          Explicitly out of scope for v1:

          Alternatives considered

          1. createSdkMcpServer + tool() for in-process tools — rejected.
          Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

          2. Do nothing — /loop already works, just tell users to type it.
          Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

          3. Poll CronList from T3 instead of subscribing the Stop hook.
          Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

          4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
          Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

          5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
          Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

          6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
          The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

          Risks or tradeoffs

          Seam cost (per docs/t3x/SEAMS.md)

          The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

          Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

          FilePhasefork ΔchurnriskStatus
          apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
          apps/server/src/mcp/McpHttpServer.ts2~6636New row
          apps/server/src/mcp/McpSessionRegistry.ts2~177New row
          apps/server/src/mcp/McpInvocationContext.ts2~236New row
          apps/server/src/t3x/index.ts100Fork-owned aggregator
          apps/server/src/server.ts029Untouched (existing row)

          Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

          Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

          Correctness and product risks

          The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

          A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

          CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

          The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

          Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

          Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

          Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

          Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

          Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

          Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

          Examples or references

          Related issues — this is adjacent to, not a duplicate of, several open items

          A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

          Upstream — scheduling (the closest cluster):

          Upstream — app-native tools:

          Fork:

          Evidence index

          Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

          • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
          • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
          • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
          • sdk.d.ts:4204-4222SessionCronSummary
          • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
          • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
          • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

          Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

          • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
          • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
          • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
          • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
          • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
          • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
          • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
          • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
          • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

          Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

          T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

          • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
          • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
          • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
          • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
          • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
          • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
          • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
          • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
          • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
          • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
          • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
          • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
          • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
          • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

          Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
          ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


          Duplicate search performed before filing

          Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

          Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

          Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

          Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

          Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

          Contribution

          • I would be open to helping implement this.

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            enhancementNew feature or request

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

              Description

              @radroid

              Before submitting

              • I searched existing issues and did not find a duplicate.
              • I am describing a concrete problem or use case, not just a vague idea.

              Area

              apps/server

              Problem or use case

              The claim this issue exists to correct

              An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

              Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

              • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
              • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
              • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
              • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
              • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
              • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

              Why nothing in T3 blocks it

              • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
              • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
              • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
              • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

              So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

              The three real gaps

              1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

              2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

              3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

              Claude-only, and that is a product problem

              CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

              Proposed solution

              Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


              Phase 0 — settle it empirically before writing code

              Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

              Experiment A — does a cron-fired turn actually reach T3's runtime?
              Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

              Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

              Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

              Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

              If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


              Phase 1 — observe, surface, and bound (the shippable slice)

              1a. Subscribe the Stop hook and read session_crons

              options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

              sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

              This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

              Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

              ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

              Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

              Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

              • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
              • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
              • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

              Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

              1b. Surface it: GET /api/t3x/loop-crons

              Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

              Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

              1c. Web: one line in the existing fork-owned overlay

              Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

              • Wakes 03:40 · self-paced when session_crons is non-empty
              • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
              • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

              Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

              1d. Bound it

              Before Phase 1 ships, close the "model arms itself unattended" hole:

              • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
              • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
              • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

              Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

              Should T3 hand models its own tools at all? Yes. It already does.

              mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

              Reject createSdkMcpServer / tool()

              They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

              1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
              2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
              3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

              The starter set (four tools, one new toolkit)

              New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

              ToolContractWhy
              wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
              loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
              loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
              thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

              Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

              Phase 2 seam cost is real and is why it is Phase 2

              The capability gate is not extensible without touching three upstream files:

              • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
              • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
              • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

              There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


              How this composes with Loop Watch (#38)

              They are complementary, and the layering is clean because of one property worth stating precisely.

              #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

              A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

              While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

              That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

              Precedence when both are armed

              There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

              Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

              #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

              This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

              Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

              One correction #38 needs regardless

              #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


              Files this touches

              New, fork-owned (zero conflict surface):

              Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

              Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

              Why this matters

              It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

              It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

              It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

              It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

              The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

              Smallest useful scope

              Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

              Concretely, one genuinely shippable first pass:

              1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
              2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
              3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
              4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
              5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
              6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

              Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

              Explicitly out of scope for v1:

              Alternatives considered

              1. createSdkMcpServer + tool() for in-process tools — rejected.
              Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

              2. Do nothing — /loop already works, just tell users to type it.
              Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

              3. Poll CronList from T3 instead of subscribing the Stop hook.
              Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

              4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
              Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

              5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
              Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

              6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
              The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

              Risks or tradeoffs

              Seam cost (per docs/t3x/SEAMS.md)

              The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

              Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

              FilePhasefork ΔchurnriskStatus
              apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
              apps/server/src/mcp/McpHttpServer.ts2~6636New row
              apps/server/src/mcp/McpSessionRegistry.ts2~177New row
              apps/server/src/mcp/McpInvocationContext.ts2~236New row
              apps/server/src/t3x/index.ts100Fork-owned aggregator
              apps/server/src/server.ts029Untouched (existing row)

              Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

              Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

              Correctness and product risks

              The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

              A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

              CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

              The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

              Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

              Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

              Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

              Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

              Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

              Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

              Examples or references

              Related issues — this is adjacent to, not a duplicate of, several open items

              A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

              Upstream — scheduling (the closest cluster):

              Upstream — app-native tools:

              Fork:

              Evidence index

              Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

              • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
              • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
              • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
              • sdk.d.ts:4204-4222SessionCronSummary
              • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
              • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
              • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

              Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

              • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
              • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
              • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
              • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
              • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
              • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
              • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
              • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
              • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

              Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

              T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

              • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
              • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
              • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
              • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
              • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
              • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
              • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
              • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
              • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
              • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
              • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
              • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
              • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
              • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

              Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
              ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


              Duplicate search performed before filing

              Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

              Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

              Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

              Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

              Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

              Contribution

              • I would be open to helping implement this.

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                enhancementNew feature or request

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

                  Description

                  @radroid

                  Before submitting

                  • I searched existing issues and did not find a duplicate.
                  • I am describing a concrete problem or use case, not just a vague idea.

                  Area

                  apps/server

                  Problem or use case

                  The claim this issue exists to correct

                  An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

                  Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

                  • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
                  • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
                  • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
                  • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
                  • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
                  • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                  Why nothing in T3 blocks it

                  • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
                  • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
                  • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
                  • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

                  So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

                  The three real gaps

                  1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

                  2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

                  3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

                  Claude-only, and that is a product problem

                  CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

                  Proposed solution

                  Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


                  Phase 0 — settle it empirically before writing code

                  Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

                  Experiment A — does a cron-fired turn actually reach T3's runtime?
                  Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

                  Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

                  Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

                  Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

                  If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


                  Phase 1 — observe, surface, and bound (the shippable slice)

                  1a. Subscribe the Stop hook and read session_crons

                  options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

                  sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

                  This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

                  Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

                  ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

                  Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

                  Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

                  • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
                  • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
                  • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

                  Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

                  1b. Surface it: GET /api/t3x/loop-crons

                  Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

                  Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

                  1c. Web: one line in the existing fork-owned overlay

                  Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

                  • Wakes 03:40 · self-paced when session_crons is non-empty
                  • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
                  • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

                  Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

                  1d. Bound it

                  Before Phase 1 ships, close the "model arms itself unattended" hole:

                  • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
                  • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
                  • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

                  Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

                  Should T3 hand models its own tools at all? Yes. It already does.

                  mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

                  Reject createSdkMcpServer / tool()

                  They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

                  1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
                  2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
                  3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

                  The starter set (four tools, one new toolkit)

                  New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

                  ToolContractWhy
                  wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
                  loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
                  loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
                  thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

                  Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

                  Phase 2 seam cost is real and is why it is Phase 2

                  The capability gate is not extensible without touching three upstream files:

                  • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
                  • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
                  • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

                  There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


                  How this composes with Loop Watch (#38)

                  They are complementary, and the layering is clean because of one property worth stating precisely.

                  #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

                  A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

                  While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

                  That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

                  Precedence when both are armed

                  There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

                  Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

                  #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

                  This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

                  Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

                  One correction #38 needs regardless

                  #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


                  Files this touches

                  New, fork-owned (zero conflict surface):

                  Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

                  Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

                  Why this matters

                  It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

                  It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

                  It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

                  It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

                  The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

                  Smallest useful scope

                  Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

                  Concretely, one genuinely shippable first pass:

                  1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
                  2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
                  3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
                  4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
                  5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
                  6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

                  Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

                  Explicitly out of scope for v1:

                  Alternatives considered

                  1. createSdkMcpServer + tool() for in-process tools — rejected.
                  Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

                  2. Do nothing — /loop already works, just tell users to type it.
                  Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

                  3. Poll CronList from T3 instead of subscribing the Stop hook.
                  Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

                  4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
                  Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

                  5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
                  Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

                  6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
                  The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

                  Risks or tradeoffs

                  Seam cost (per docs/t3x/SEAMS.md)

                  The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

                  Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

                  FilePhasefork ΔchurnriskStatus
                  apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
                  apps/server/src/mcp/McpHttpServer.ts2~6636New row
                  apps/server/src/mcp/McpSessionRegistry.ts2~177New row
                  apps/server/src/mcp/McpInvocationContext.ts2~236New row
                  apps/server/src/t3x/index.ts100Fork-owned aggregator
                  apps/server/src/server.ts029Untouched (existing row)

                  Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

                  Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

                  Correctness and product risks

                  The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

                  A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

                  CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

                  The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

                  Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

                  Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

                  Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

                  Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

                  Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

                  Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

                  Examples or references

                  Related issues — this is adjacent to, not a duplicate of, several open items

                  A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

                  Upstream — scheduling (the closest cluster):

                  Upstream — app-native tools:

                  Fork:

                  Evidence index

                  Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

                  • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
                  • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
                  • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
                  • sdk.d.ts:4204-4222SessionCronSummary
                  • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
                  • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
                  • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

                  Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

                  • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
                  • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
                  • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
                  • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
                  • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
                  • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
                  • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
                  • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
                  • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

                  Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                  T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

                  • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
                  • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
                  • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
                  • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
                  • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
                  • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
                  • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
                  • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
                  • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
                  • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
                  • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
                  • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
                  • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
                  • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

                  Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
                  ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


                  Duplicate search performed before filing

                  Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

                  Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

                  Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

                  Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

                  Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

                  Contribution

                  • I would be open to helping implement this.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    enhancementNew feature or request

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

                      Description

                      @radroid

                      Before submitting

                      • I searched existing issues and did not find a duplicate.
                      • I am describing a concrete problem or use case, not just a vague idea.

                      Area

                      apps/server

                      Problem or use case

                      The claim this issue exists to correct

                      An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

                      Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

                      • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
                      • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
                      • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
                      • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
                      • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
                      • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                      Why nothing in T3 blocks it

                      • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
                      • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
                      • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
                      • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

                      So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

                      The three real gaps

                      1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

                      2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

                      3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

                      Claude-only, and that is a product problem

                      CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

                      Proposed solution

                      Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


                      Phase 0 — settle it empirically before writing code

                      Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

                      Experiment A — does a cron-fired turn actually reach T3's runtime?
                      Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

                      Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

                      Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

                      Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

                      If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


                      Phase 1 — observe, surface, and bound (the shippable slice)

                      1a. Subscribe the Stop hook and read session_crons

                      options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

                      sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

                      This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

                      Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

                      ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

                      Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

                      Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

                      • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
                      • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
                      • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

                      Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

                      1b. Surface it: GET /api/t3x/loop-crons

                      Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

                      Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

                      1c. Web: one line in the existing fork-owned overlay

                      Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

                      • Wakes 03:40 · self-paced when session_crons is non-empty
                      • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
                      • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

                      Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

                      1d. Bound it

                      Before Phase 1 ships, close the "model arms itself unattended" hole:

                      • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
                      • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
                      • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

                      Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

                      Should T3 hand models its own tools at all? Yes. It already does.

                      mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

                      Reject createSdkMcpServer / tool()

                      They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

                      1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
                      2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
                      3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

                      The starter set (four tools, one new toolkit)

                      New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

                      ToolContractWhy
                      wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
                      loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
                      loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
                      thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

                      Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

                      Phase 2 seam cost is real and is why it is Phase 2

                      The capability gate is not extensible without touching three upstream files:

                      • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
                      • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
                      • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

                      There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


                      How this composes with Loop Watch (#38)

                      They are complementary, and the layering is clean because of one property worth stating precisely.

                      #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

                      A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

                      While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

                      That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

                      Precedence when both are armed

                      There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

                      Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

                      #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

                      This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

                      Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

                      One correction #38 needs regardless

                      #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


                      Files this touches

                      New, fork-owned (zero conflict surface):

                      Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

                      Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

                      Why this matters

                      It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

                      It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

                      It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

                      It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

                      The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

                      Smallest useful scope

                      Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

                      Concretely, one genuinely shippable first pass:

                      1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
                      2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
                      3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
                      4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
                      5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
                      6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

                      Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

                      Explicitly out of scope for v1:

                      Alternatives considered

                      1. createSdkMcpServer + tool() for in-process tools — rejected.
                      Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

                      2. Do nothing — /loop already works, just tell users to type it.
                      Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

                      3. Poll CronList from T3 instead of subscribing the Stop hook.
                      Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

                      4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
                      Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

                      5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
                      Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

                      6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
                      The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

                      Risks or tradeoffs

                      Seam cost (per docs/t3x/SEAMS.md)

                      The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

                      Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

                      FilePhasefork ΔchurnriskStatus
                      apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
                      apps/server/src/mcp/McpHttpServer.ts2~6636New row
                      apps/server/src/mcp/McpSessionRegistry.ts2~177New row
                      apps/server/src/mcp/McpInvocationContext.ts2~236New row
                      apps/server/src/t3x/index.ts100Fork-owned aggregator
                      apps/server/src/server.ts029Untouched (existing row)

                      Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

                      Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

                      Correctness and product risks

                      The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

                      A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

                      CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

                      The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

                      Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

                      Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

                      Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

                      Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

                      Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

                      Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

                      Examples or references

                      Related issues — this is adjacent to, not a duplicate of, several open items

                      A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

                      Upstream — scheduling (the closest cluster):

                      Upstream — app-native tools:

                      Fork:

                      Evidence index

                      Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

                      • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
                      • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
                      • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
                      • sdk.d.ts:4204-4222SessionCronSummary
                      • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
                      • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
                      • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

                      Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

                      • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
                      • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
                      • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
                      • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
                      • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
                      • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
                      • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
                      • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
                      • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

                      Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                      T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

                      • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
                      • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
                      • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
                      • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
                      • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
                      • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
                      • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
                      • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
                      • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
                      • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
                      • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
                      • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
                      • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
                      • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

                      Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
                      ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


                      Duplicate search performed before filing

                      Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

                      Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

                      Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

                      Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

                      Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

                      Contribution

                      • I would be open to helping implement this.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        enhancementNew feature or request

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

                          Description

                          @radroid

                          Before submitting

                          • I searched existing issues and did not find a duplicate.
                          • I am describing a concrete problem or use case, not just a vague idea.

                          Area

                          apps/server

                          Problem or use case

                          The claim this issue exists to correct

                          An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

                          Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

                          • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
                          • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
                          • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
                          • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
                          • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
                          • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                          Why nothing in T3 blocks it

                          • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
                          • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
                          • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
                          • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

                          So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

                          The three real gaps

                          1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

                          2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

                          3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

                          Claude-only, and that is a product problem

                          CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

                          Proposed solution

                          Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


                          Phase 0 — settle it empirically before writing code

                          Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

                          Experiment A — does a cron-fired turn actually reach T3's runtime?
                          Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

                          Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

                          Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

                          Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

                          If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


                          Phase 1 — observe, surface, and bound (the shippable slice)

                          1a. Subscribe the Stop hook and read session_crons

                          options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

                          sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

                          This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

                          Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

                          ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

                          Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

                          Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

                          • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
                          • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
                          • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

                          Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

                          1b. Surface it: GET /api/t3x/loop-crons

                          Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

                          Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

                          1c. Web: one line in the existing fork-owned overlay

                          Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

                          • Wakes 03:40 · self-paced when session_crons is non-empty
                          • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
                          • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

                          Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

                          1d. Bound it

                          Before Phase 1 ships, close the "model arms itself unattended" hole:

                          • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
                          • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
                          • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

                          Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

                          Should T3 hand models its own tools at all? Yes. It already does.

                          mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

                          Reject createSdkMcpServer / tool()

                          They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

                          1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
                          2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
                          3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

                          The starter set (four tools, one new toolkit)

                          New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

                          ToolContractWhy
                          wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
                          loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
                          loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
                          thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

                          Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

                          Phase 2 seam cost is real and is why it is Phase 2

                          The capability gate is not extensible without touching three upstream files:

                          • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
                          • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
                          • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

                          There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


                          How this composes with Loop Watch (#38)

                          They are complementary, and the layering is clean because of one property worth stating precisely.

                          #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

                          A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

                          While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

                          That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

                          Precedence when both are armed

                          There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

                          Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

                          #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

                          This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

                          Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

                          One correction #38 needs regardless

                          #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


                          Files this touches

                          New, fork-owned (zero conflict surface):

                          Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

                          Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

                          Why this matters

                          It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

                          It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

                          It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

                          It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

                          The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

                          Smallest useful scope

                          Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

                          Concretely, one genuinely shippable first pass:

                          1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
                          2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
                          3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
                          4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
                          5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
                          6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

                          Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

                          Explicitly out of scope for v1:

                          Alternatives considered

                          1. createSdkMcpServer + tool() for in-process tools — rejected.
                          Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

                          2. Do nothing — /loop already works, just tell users to type it.
                          Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

                          3. Poll CronList from T3 instead of subscribing the Stop hook.
                          Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

                          4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
                          Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

                          5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
                          Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

                          6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
                          The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

                          Risks or tradeoffs

                          Seam cost (per docs/t3x/SEAMS.md)

                          The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

                          Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

                          FilePhasefork ΔchurnriskStatus
                          apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
                          apps/server/src/mcp/McpHttpServer.ts2~6636New row
                          apps/server/src/mcp/McpSessionRegistry.ts2~177New row
                          apps/server/src/mcp/McpInvocationContext.ts2~236New row
                          apps/server/src/t3x/index.ts100Fork-owned aggregator
                          apps/server/src/server.ts029Untouched (existing row)

                          Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

                          Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

                          Correctness and product risks

                          The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

                          A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

                          CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

                          The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

                          Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

                          Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

                          Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

                          Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

                          Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

                          Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

                          Examples or references

                          Related issues — this is adjacent to, not a duplicate of, several open items

                          A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

                          Upstream — scheduling (the closest cluster):

                          Upstream — app-native tools:

                          Fork:

                          Evidence index

                          Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

                          • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
                          • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
                          • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
                          • sdk.d.ts:4204-4222SessionCronSummary
                          • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
                          • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
                          • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

                          Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

                          • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
                          • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
                          • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
                          • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
                          • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
                          • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
                          • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
                          • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
                          • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

                          Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                          T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

                          • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
                          • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
                          • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
                          • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
                          • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
                          • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
                          • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
                          • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
                          • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
                          • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
                          • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
                          • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
                          • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
                          • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

                          Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
                          ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


                          Duplicate search performed before filing

                          Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

                          Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

                          Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

                          Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

                          Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

                          Contribution

                          • I would be open to helping implement this.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            enhancementNew feature or request

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              [Feature]: Self-paced loops — the Claude binary can already wake a T3 thread; surface it, bound it, and give models a durable T3-native wake_me instead #42

                              Description

                              @radroid

                              Before submitting

                              • I searched existing issues and did not find a duplicate.
                              • I am describing a concrete problem or use case, not just a vague idea.

                              Area

                              apps/server

                              Problem or use case

                              The claim this issue exists to correct

                              An earlier round of analysis in this fork concluded that the Claude Agent SDK offers no scheduling primitive, and that self-paced /loop therefore could not work inside T3 Code. That conclusion was wrong, and it was wrong for a specific, repeatable reason: it read sdk.d.ts / sdk.mjs and stopped there. The scheduler and the scheduling tools are not in the npm package. They are compiled into the 222 MB platform binary that sdk.mjs spawns.

                              Verified on this machine, against @anthropic-ai/claude-agent-sdk@0.3.170:

                              • The SDK's public type surface already declares the tools as model-callable CLI tool inputs, not harness APIs: sdk-tools.d.ts:9-40export type ToolInputSchemas = | AgentInput | BashInput | ... | CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput | ....
                              • The shipped JS bundles contain zero occurrences of CronCreate / ScheduleWakeup (grep -c over sdk.mjs, assistant.mjs, bridge.mjs, browser-sdk.js0 0 0 0). The platform binary at node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude contains CronCreate ×21, ScheduleWakeup ×8, scheduled_tasks.json ×23. Re-verified in this session.
                              • All four scheduling tools are spread unconditionally into the binary's master tool registry (function TQ(){return[...,...m$3,MVK,...]} where m$3 = [CronCreateTool, CronDeleteTool, CronListTool] and MVK = ScheduleWakeupTool). Only per-tool isEnabled() filters them.
                              • CronCreate.isEnabled() defaults to true with only an env kill switch — no interactive/REPL check: function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron", !0, ...)}.
                              • Decisive: the cron scheduler is constructed inside print.ts — the non-interactive, stream-json / SDK entrypoint, the same function that emits tengu_sdk_result and control_response — not only in the Ink REPL hook. On fire it does Ij({mode:"prompt", value:G6, uuid:…, priority:"later", isMeta:!0, …}); t6("cron_fire"); _6(); — i.e. the binary injects a synthetic user prompt into the live session and kicks its own drain loop. The host harness is never asked.
                              • Gate cache on this account (~/.claude.json, 442 entries, re-verified this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                              Why nothing in T3 blocks it

                              • queryOptions (apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562) passes noallowedTools, nodisallowedTools, notoolAliases, nohooks. Nothing gates the scheduling tools.
                              • In full-access, canUseTool returns { behavior: "allow", updatedInput: toolInput } for everything (ClaudeAdapter.ts:3373-3378), and permissionMode maps to bypassPermissions (:3512-3517).
                              • Firing requires the input stream to stay open. T3 runs exactly that shape: const promptQueue = yield* Queue.unbounded<PromptQueueItem>()Stream.toAsyncIterable, never closed between turns (ClaudeAdapter.ts:3181-3189).
                              • A cron-fired turn arrives as assistant output with no active turn — and T3 already handles that: ClaudeAdapter.ts:2468// Auto-start a synthetic turn for assistant messages that arrive without…, emitting turn.started with raw.method: "claude/synthetic-turn-start" at :2504, closed normally by handleResultMessage (:2548-2563).

                              So /loop <prompt> typed into a T3 composer today plausibly already works end-to-end with zero code changes. That is the finding. What follows is why it is still not a shippable feature.

                              The three real gaps

                              1. Loops are session-lifetime only, and T3 reaps sessions at 30 minutes.tengu_kairos_cron_durable = false, so durable:true is silently downgraded to session-only and the cron lives in the binary's in-process table. Meanwhile ProviderSessionReaper.ts:17 sets DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000, and the reaper skips a binding only when thread.session.activeTurnId != null (:63-71) — a pending wake is not an active turn. ScheduleWakeup clamps delaySeconds to [60, 3600] (sdk-tools.d.ts:2324-2333). Any self-paced delay above ~1800s is therefore likely dead on arrival: the session is stopped before the wake fires, and nothing on disk records that it was ever scheduled.

                              2. It rests on a remote gate with no local override.ScheduleWakeup's runtime is function zTH(){return j_("tengu_kairos_loop_dynamic", !1)}code default false, currently true only because a GrowthBook evaluation was cached to ~/.claude.json. When off, the tool returns gate_off and the loop just... ends. There is no env escape hatch. Worse, ClaudeHome.ts:22-34 relocates CLAUDE_CONFIG_DIR per provider instance, so the same T3 build can have working self-paced loops on the default Claude instance and silently dead ones on an isolated instance, because the gate cache moved and the code default took over.

                              3. T3 has no product concept of an unattended turn. The runtime already emits turn.started for a turn nobody asked for, but there is no "this thread wakes at 03:40" affordance, no cancel, no budget, no cost ceiling. And outside full-access, CronCreate has no checkPermissions (unlike ScheduleWakeup, which self-permits with {behavior:"allow"}), so it routes to request.opened (ClaudeAdapter.ts:3380-3436) and pops an approval card at 2am that nobody sees.

                              Claude-only, and that is a product problem

                              CronCreate / ScheduleWakeup exist under claudeAgent and nowhere else. codex, cursor, grok, opencode have no equivalent. Any UI built directly on the binary's crons is a dead affordance on four of five adapters.

                              Proposed solution

                              Three phases. Phase 0 is a measurement with no code. Phase 1 is the shippable slice and costs one seam row. Phase 2 is the durable fix and is explicitly deferred.


                              Phase 0 — settle it empirically before writing code

                              Nothing below was executed. This is a static case, however strong. Run these first and record the results in the issue thread:

                              Experiment A — does a cron-fired turn actually reach T3's runtime?
                              Start a thread in full-access on the default Claude instance (no homePath override — see Risks), send /loop 2m say hi as ordinary composer text, and watch the runtime event stream for a second turn.started carrying raw.method: "claude/synthetic-turn-start" roughly two minutes later. Pass = the whole static chain is confirmed.

                              Experiment B — do phantom user messages appear? The cron injects with isMeta:!0. Check whether that surfaces to T3 as an SDK user message and whether the transcript renders it as if the human typed it. If yes, that is a UX bug to fix before anything ships.

                              Experiment C — the reaper race. Arm a ScheduleWakeup at 3000s and confirm whether ProviderSessionReaper stops the session first (provider.session.reaped with reason: "inactivity_threshold"). This decides whether long self-paced loops are viable at all under today's defaults.

                              Experiment D — gate stability under sdk-ts.sdk.mjs sets CLAUDE_CODE_ENTRYPOINT="sdk-ts", and the binary's user-attribute builder includes entrypoint as a GrowthBook targeting attribute. The cached value may have been written by a cli evaluation. Force a fresh gate refresh from a T3-spawned process and re-read tengu_kairos_loop_dynamic.

                              If A fails, this issue collapses to Phase 2 only and Phase 1 should not be built.


                              Phase 1 — observe, surface, and bound (the shippable slice)

                              1a. Subscribe the Stop hook and read session_crons

                              options.hooks is set nowhere in this repo (hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>> at sdk.d.ts:1486; 30 HookEvent values at sdk.d.ts:821; zero subscriptions in apps/server/src). This is the single largest untapped SDK surface in the adapter, and it hands us exactly the fact we need:

                              sdk.d.ts:6140-6142 (and :6181-6183 for SubagentStopHookInput) — "Session-scoped cron tasks (CronCreate, ScheduleWakeup, /loop) that will wake this session later. Empty array when none are scheduled."session_crons?: SessionCronSummary[] (shape at sdk.d.ts:4204-4222).

                              This is read-only observability. It does not touch the model's tool list, does not change permissions, and turns "is this thread going to wake up again, and when?" from an inference into a fact.

                              Upstream edit (the one seam row): in ClaudeAdapter.ts:3524-3562, add a single spread to the existing queryOptions object, immediately before or after the mcpServers spread:

                              ...(loopWatch ? {hooks: loopWatch.claudeHooks(threadId)} : {}),

                              Every line of logic lives fork-side. Keep the adapter delta to ~4-6 lines so the ledger row stays cheap.

                              Fork-owned module: apps/server/src/t3x/loop/claudeCrons.ts

                              • claudeHooks(threadId) returns { Stop: [...], SubagentStop: [...] } whose callbacks read input.session_crons, normalise to { id, kind, nextFireAtMs, prompt }, and write into a fork-owned store.
                              • Store lives beside the existing pattern: durable JSON in ServerConfig.stateDir (t3x-loop-crons.json), SynchronizedRef + atomic write, exactly as apps/server/src/t3x/autoResume/ does.
                              • Registers through apps/server/src/t3x/index.ts (T3xLayerLive at :67), so server.ts gains nothing — it already has its 3-line row.

                              Never mutate the cron list from T3. T3 has no handle on the binary's in-process sessionCronTasks. Phase 1 observes only.

                              1b. Surface it: GET /api/t3x/loop-crons

                              Raw HTTP route under T3xRoutesLive, following apps/server/src/t3x/webPush/http.ts verbatim — its header comment states the rule outright: "Raw routes, not WS-RPC: an RPC would force edits to @t3tools/contracts + ws.ts + its scope map." Zero contracts change, zero ws.ts change.

                              Response: { threadId, crons: [{ id, kind, nextFireAtMs, prompt, durable: false }], degraded: null | "gate_off" | "session_reaped" }.

                              1c. Web: one line in the existing fork-owned overlay

                              Issue #38's design already specifies apps/web/src/t3x/ThreadT3xOverlay.tsx — a fork-owned aggregator rendering AutoResumeOverlay + LoopPill in one absolutely-positioned column. The wake indicator goes inside LoopPill, not into a new component and not into any upstream file. Collapsed face gains one line:

                              • Wakes 03:40 · self-paced when session_crons is non-empty
                              • Self-pacing unavailable (gate off) when ScheduleWakeup returned gate_off — the visible degraded state the remote gate demands
                              • Wake lost — session reaped when the store held a pending cron and the binding was stopped by the reaper

                              Expanded: a Cancel wakes button. It cannot delete the binary's cron directly, so it does the honest thing — providerService.stopSession({ threadId }), which kills the session and therefore its session-only crons. Label it as such.

                              1d. Bound it

                              Before Phase 1 ships, close the "model arms itself unattended" hole:

                              • Default posture: unchanged for auto / auto-accept-editsCronCreate already raises a normal T3 approval card there (it has validateInput but no checkPermissions). ScheduleWakeup self-permits (async checkPermissions(H){return{behavior:"allow",updatedInput:H}}) and cannot be gated by canUseTool.
                              • full-access needs an explicit decision. Today a model in a full-access T3 thread can arm up to 50 recurring jobs (var lyK=50, "Too many scheduled jobs (max 50)") re-firing for 7 days, with no human in the path. Add a fork-owned per-thread toggle (default off) that, when off, sets CLAUDE_CODE_DISABLE_CRON=1 in claudeEnvironment. That env var is the binary's own kill switch (!__(process.env.CLAUDE_CODE_DISABLE_CRON)), it is already an env-shaped decision (ClaudeHome.ts:makeClaudeEnvironment is the precedent), and it costs zero additional upstream lines because env: claudeEnvironment is already in queryOptions. Note it does not stop ScheduleWakeup — only CronCreate.
                              • Cost ceiling. A self-paced loop at the default 1200-1800s cadence is ~2-3 uncached full-context reads/hour, indefinitely. maxBudgetUsd (sdk.d.ts:1648) and taskBudget (:1656) are both unset today. Either wire maxBudgetUsd (adds nothing to the seam — same queryOptions object) or reuse [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38's maxNudges / deadlineAtMs accounting. Pick one; do not ship neither.

                              Phase 2 — app-native tools: yes, but over HTTP MCP, not createSdkMcpServer

                              Should T3 hand models its own tools at all? Yes. It already does.

                              mcpServers is wired in all five adapters to a T3-hosted HTTP MCP server with a per-thread bearer credential: ClaudeAdapter.ts:3549-3561, CursorAdapter.ts:544, GrokAdapter.ts:582, CodexAdapter.ts:1422, OpenCodeAdapter.ts:1221. The invocation scope carries the calling thread: McpInvocationContext.ts:11-19{ environmentId, threadId, providerSessionId, providerInstanceId, capabilities, issuedAt }. There is a complete working template at apps/server/src/mcp/toolkits/preview/ (tools.ts, handlers.ts, and both test files).

                              Reject createSdkMcpServer / tool()

                              They exist and are typed (sdk.d.ts:485, :487-506, :6288-6292) and nothing in the repo imports them (verified: zero hits under apps/server/src). They are still the wrong choice here on three counts:

                              1. Claude-only. The other four adapters get nothing, which is the exact fragmentation this issue is trying to avoid.
                              2. Requires a ClaudeAdapter edit anyway — so it does not even save a seam row versus the HTTP path.
                              3. Runs in the Effect server process — duplicating a host that already exists with auth, per-thread scoping and a capability gate.

                              The starter set (four tools, one new toolkit)

                              New toolkit apps/server/src/mcp/toolkits/loop/ behind a new McpCapability"loop", default off, opt-in per session:

                              ToolContractWhy
                              wake_me{ delaySeconds: 60..86400, note?: string }{ wakeAtMs }The durable fix. Persists to T3's own fork-owned store, survives server restart, is gate-independent, and re-invokes via engine.dispatch({ type: "thread.turn.start", … }) — byte-for-byte the path AutoResumeReactor.ts:109 already uses. No 3600s clamp, no reaper race (T3 restarts the session itself).
                              loop_status{}{ wakesRemaining, deadlineAtMs, nudgeCount, budgetUsdRemaining }Lets the model self-limit instead of being cut off silently. Directly reads #38's per-thread record.
                              loop_stop{ reason: string }{ ok: true }The model declares itself done. This is the durable replacement for #38's .t3x/loop-done sentinel file, which has a real failure mode: resolveThreadWorkspaceCwd (checkpointing/Utils.ts:12-27) returns worktreePath first, so an agent writing the sentinel and a supervisor stat'ing the project root disagree on any worktree-backed thread. A tool call has no cwd ambiguity.
                              thread_note{ text: string, level?: "info" | "warn" }{ ok: true }Appends a t3x.loop.* activity breadcrumb so the unattended trail is legible on every provider, exactly as AutoResumeReactor does for resume decisions.

                              Deliberately excluded: anything spawn/delegate-shaped. Cross-provider dispatch is upstream pingdotgg#3138 and is already partly built on the orchestrator-v2 branch — building a fork-local parallel path there is the known "parallel paths" hazard. Also excluded: filesystem/shell tools; every provider already has better ones.

                              Phase 2 seam cost is real and is why it is Phase 2

                              The capability gate is not extensible without touching three upstream files:

                              • McpInvocationContext.ts:11export type McpCapability = "preview"; (closed union, +2 lines, churn 3)
                              • McpSessionRegistry.ts:131capabilities: new Set(["preview"]) hardcoded (+1 line, churn 7)
                              • McpHttpServer.ts:206-225 — toolkit registration (+~6 lines, churn 6)

                              There is also a naming trap: requireMcpCapability fails with PreviewAutomationUnavailableError (McpInvocationContext.ts:29-38), which is preview-specific. A "loop" capability either reuses a misnamed error or needs a @t3tools/contracts change — the latter is a much worse row. Reuse the misnamed error and leave a comment.


                              How this composes with Loop Watch (#38)

                              They are complementary, and the layering is clean because of one property worth stating precisely.

                              #38's trigger is now - projection_threads.updated_at. Its design (docs/t3x/loop/DESIGN.md §1, on branch t3x/loop-supervisor, commit cbb3a1373) establishes that thread.activity-appended is grouped with thread.message-sent in ProjectionPipeline.ts:794-808 and rewrites the row with updatedAt: event.occurredAt.

                              A cron-fired turn produces turn.started → messages → turn.completed, all of which bump that column. Therefore:

                              While self-pacing is working, Loop Watch stays silent by construction. It only fires when self-pacing has actually died — gate flipped off, session reaped, server restarted, or the model simply stopped calling ScheduleWakeup.

                              That is the correct relationship: agent self-pacing is the inner loop; Loop Watch is the deadman's switch around it. Neither subsumes the other.

                              Precedence when both are armed

                              There is exactly one conflict, and it is a timing conflict. #38's default fuse is idleMs 15 min / busyIdleMs 45 min. ScheduleWakeup permits delays up to 3600s. A model that self-paces at 30 minutes gets nudged by Loop Watch at 15 — mid-wait, uninvited, burning a nudge from a budget of 6.

                              Fix: add Guard #15 to the ordered table in docs/t3x/loop/DESIGN.md §5, placed immediately after Guard #9 (autoResumeStore.getThread(threadId).pending === null), which it exactly parallels:

                              #15loopCronStore.getThread(threadId).nextFireAtMs == null || now >= nextFireAtMs. Skip, keep budget. A thread with a scheduled wake is not idle; it is waiting. Non-consuming, surfaced in the pill as Loop paused — self-pacing.

                              This is the same non-consuming-skip class as #38's existing snoozedUntil / settledOverride === "settled" / hasPendingApprovals skips, and it inherits their "surface the refusal" rule — #38's own design note says "Correct behaviour that reads as a bug is a bug."

                              Which wins: the agent wins while it is demonstrably still driving. Loop Watch wins the moment the wake is overdue — now >= nextFireAtMs + graceMs (grace ≥ the binary's cron jitter) means the wake did not land, and the deadman fires with a nudge that explicitly says so. Loop Watch's hard stops (maxNudges, deadlineAtMs, maxArmedThreads 3, human takeover ⇒ disarm) remain the outer ceiling and are never relaxed by self-pacing. A self-paced thread that never goes idle must still die at deadlineAtMs.

                              One correction #38 needs regardless

                              #38's memo-level note "never gate on session.status — synthetic turns deadlock it" was written before we knew a session can legitimately wake itself. Cron-fired turns are synthetic turns (ClaudeAdapter.ts:2468-2507). That note is now doubly load-bearing, and #38's guard table (which correctly contains session.statusnowhere — its design calls that "the single most important line") must be re-validated against a thread that self-wakes, not just one whose subagents are noisy.


                              Files this touches

                              New, fork-owned (zero conflict surface):

                              Upstream-owned, Phase 1:apps/server/src/provider/Layers/ClaudeAdapter.ts only — one spread in queryOptions at :3524-3562.

                              Registration:apps/server/src/t3x/index.ts (T3xLayerLive:67, T3xRoutesLive), churn 0. server.ts unchanged — its 3-line row already exists.

                              Why this matters

                              It corrects a wrong conclusion that is currently steering fork architecture. Loop Watch (#38) was designed on the premise that the model cannot wake itself, so staleness inference was the only trigger available. It can wake itself. session_crons turns "is this thread stale or thinking?" from a heuristic into a fact the runtime already knows, which is strictly better than the inference for the case it covers — and #38's staleness trigger remains exactly right for the case it does not.

                              It makes overnight runs survivable rather than lucky. The incident behind #38 was a thread that went silent for 3h31m and 6h50m until a human typed. Agent self-pacing plus a deadman's switch is a genuinely different reliability posture from either alone: the agent handles the normal case at its own cadence, and the supervisor handles the case where the agent's own scheduling died — including the gate-flip and reaper failure modes that only exist because it self-paces.

                              It closes an unaudited safety hole that is open right now. Not hypothetically: a Claude thread in full-access today can arm up to 50 recurring jobs re-firing for 7 days with no human in the path, because canUseTool auto-allows everything (ClaudeAdapter.ts:3373-3378) and ScheduleWakeup self-permits. T3 has never made a policy decision about that. Phase 1d makes it an explicit, defaulted-off choice.

                              It opens options.hooks — 30 events, zero subscriptions today.Stop / SubagentStop alone yield session_crons plus background-task state, i.e. the "paused vs finished" distinction that both the needs-input coordinator (#11 → PR #14) and Loop Watch (#38) currently infer from weaker signals. The one adapter line this issue adds is the beachhead for both.

                              The app-native-tools half generalises past Claude. Every adapter already mounts t3-code. A T3-owned wake_me is gate-independent, restart-durable, has no 3600s clamp, and inherits every downstream capability the thread already has. It is the only path where "self-paced loop" means the same thing on Cursor and OpenCode as it does on Claude.

                              Smallest useful scope

                              Phase 0 + Phase 1a/1b/1c/1d, gated on Experiment A passing.

                              Concretely, one genuinely shippable first pass:

                              1. Run Experiment A. Full-access thread, /loop 2m say hi, watch for a second turn.started with raw.method: "claude/synthetic-turn-start". Post the result in the issue. If it fails, stop — do not build Phase 1.
                              2. One upstream line: a hooks spread in ClaudeAdapter.tsqueryOptions (:3524-3562), delegating entirely to fork code.
                              3. apps/server/src/t3x/loop/claudeCrons.ts + cronStore.tsStop / SubagentStop callbacks read input.session_crons, persist { threadId, id, kind, nextFireAtMs, prompt } to t3x-loop-crons.json, register via T3xLayerLive.
                              4. GET /api/t3x/loop-crons on T3xRoutesLive, following t3x/webPush/http.ts. No contracts, no ws.ts.
                              5. One line in the fork-owned LoopPillWakes 03:40 · self-paced, plus the two degraded states (gate off, session reaped) and a Cancel that calls stopSession.
                              6. The CLAUDE_CODE_DISABLE_CRON toggle, default off for full-access (i.e. crons disabled unless the user opts in). Zero extra upstream lines — env: claudeEnvironment is already in queryOptions.

                              Ledger impact: one new row (ClaudeAdapter.ts, ~5 lines × churn 12 ≈ risk 60).

                              Explicitly out of scope for v1:

                              Alternatives considered

                              1. createSdkMcpServer + tool() for in-process tools — rejected.
                              Exported and typed (sdk.d.ts:485, :487-506, :6288-6292), unused anywhere in the repo. Rejected because it is Claude-only (the other four adapters get nothing), it requires a ClaudeAdapter.ts edit anyway so it saves no seam cost versus HTTP MCP, and it duplicates a host that already exists at apps/server/src/mcp/McpHttpServer.ts with auth, per-thread scoping and a capability gate. The only case for it is latency, which is irrelevant for a tool that schedules something minutes away.

                              2. Do nothing — /loop already works, just tell users to type it.
                              Cheapest, and honestly defensible until Experiment A runs. Rejected as a destination because of the three gaps: loops die at the 30-minute reaper with no trace, the gate can flip off silently, and there is no cancel, no budget and no indication a thread will wake. It "works" the way an unlogged background process works.

                              3. Poll CronList from T3 instead of subscribing the Stop hook.
                              Rejected: CronList is a model-callable tool, not a host API. T3 would have to burn a turn asking the model to enumerate its own crons — expensive, racy, and it perturbs the very session it is observing. session_crons on StopHookInput is the read-only surface, delivered free at every turn boundary.

                              4. Build wake_me first and skip the binary's crons entirely (Phase 2 as v1).
                              Genuinely tempting: it is durable, gate-independent, has no 3600s clamp and no reaper race, and works on all five providers. Rejected as v1 on seam cost and sequencing — it needs three upstream rows (McpInvocationContext.ts, McpSessionRegistry.ts, McpHttpServer.ts) plus an error-naming compromise, and it would be built without knowing whether the free path works. Phase 0's experiments are cheap and change the design. Reconsider immediately if Experiment C shows the reaper kills every meaningful self-paced delay.

                              5. Extend Loop Watch's staleness trigger to cover self-pacing, instead of reading session_crons.
                              Rejected: staleness cannot distinguish "waiting on a scheduled wake" from "dead". That is precisely the ambiguity session_crons removes. It would also mean tuning #38's fuse above 3600s to avoid nudging mid-wait, which destroys its usefulness for the incident it was designed for.

                              6. Wait for upstream's Automations & Triggers (pingdotgg#3164) / orchestrator-v2 (pingdotgg#2829).
                              The real alternative. pingdotgg#3164 is open and labelled 🚧 In Progress, and PR pingdotgg#3638 (merged into t3code/codex-turn-mapping, not an ancestor of upstream/main) already ships schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools with a scheduled_tasks table and a 5s poll loop — i.e. upstream's own version of Phase 2's wake_me, gated behind pingdotgg#2829 landing on main. Rejected for Phase 1 because Phase 1 observes a capability that exists today and costs one adapter line. It is the strongest argument for keeping Phase 2 deferred: if pingdotgg#2829 lands, Phase 2 should be dropped in favour of upstream's schedule_task rather than built as a fork-local parallel path.

                              Risks or tradeoffs

                              Seam cost (per docs/t3x/SEAMS.md)

                              The ledger stands at 34 upstream-owned files, +1616 / -187 lines against merge-base 64bf01619, and carries an explicit tripwire: "Before adding row 35, re-isolate something instead." This issue proposes crossing it. That is deliberate and must be argued in the PR, not assumed.

                              Churn measured in this session, git log --since="@$((MBTS-60*86400))" 64bf01619 -- <path>:

                              FilePhasefork ΔchurnriskStatus
                              apps/server/src/provider/Layers/ClaudeAdapter.ts1~51260New row 35
                              apps/server/src/mcp/McpHttpServer.ts2~6636New row
                              apps/server/src/mcp/McpSessionRegistry.ts2~177New row
                              apps/server/src/mcp/McpInvocationContext.ts2~236New row
                              apps/server/src/t3x/index.ts100Fork-owned aggregator
                              apps/server/src/server.ts029Untouched (existing row)

                              Phase 1 adds one row at risk 60. Phase 2 adds three more. Per the self-reference rule, docs/t3x/SEAMS.md header totals and the new row must be updated in the same commit.

                              Mitigation for row 35: ClaudeAdapter.ts is already on the fork's watch list (SEAMS.md:107, for the composerSteering.logic.ts allowlist) but is not yet a ledger row. Keeping the edit to a single conditional spread inside an object literal upstream appends to — rather than rewrites — is the cheapest possible shape. Do not add allowedTools / disallowedTools / a second spread; each one multiplies risk by churn 12.

                              Correctness and product risks

                              The whole Phase 1 premise is UNVERIFIED end-to-end. Nothing in the research was executed. The static chain is strong — registry → isEnabledprint.ts scheduler → Ij(…isMeta:!0) → synthetic turn at ClaudeAdapter.ts:2470 — but a strong static case is not a demonstration. Experiment A settles it and gates the build.

                              A remote gate can silently kill the feature.tengu_kairos_loop_dynamic defaults to false in code with no env override; it is true here only via a cached GrowthBook evaluation in ~/.claude.json. Anthropic can flip it and ScheduleWakeup starts returning gate_off. Anything built on it needs a visible degraded state — hence 1c. CronCreate is safer (default-true in code, env kill switch) and is the primitive that survives a gate flip.

                              CLAUDE_CONFIG_DIR isolation is a hidden coupling.ClaudeHome.ts:22-34 relocates the config dir per provider instance, moving the gate cache. The same T3 build can have working self-paced loops on the default instance and dead ones on an isolated instance. Any test of this feature must pin homePath to empty, or it measures the wrong thing. This is a live hazard for this fork specifically — issue #30 (multi-account Claude) is exactly the feature that populates homePath.

                              The reaper may make short delays the only viable ones.ProviderSessionReaper.ts:17 stops idle bindings at 30 min and only skips when session.activeTurnId != null (:63-71); a pending wake is not an active turn. Combined with the [60, 3600]s clamp, this plausibly leaves a usable band of roughly 60-1800s. This constraint would tighten, not loosen, if the open getSnapshot() OOM work leads to more aggressive idle teardown — a fix for memory pressure could silently kill this feature. Experiment C, then decide whether Phase 1 needs a reaper exemption for threads with a pending cron (which would be a second seam row — weigh it).

                              Phantom user messages. The cron injects with isMeta:!0. If that surfaces as an SDK user message, transcripts will show messages the human never typed. Experiment B; fix before shipping if confirmed.

                              Cross-process lock contention, unconfirmed. The binary uses .claude/scheduled_tasks.lock, one holder per config dir. With multiple concurrent T3 Claude threads sharing one CLAUDE_CONFIG_DIR, only one process holds it. Session-only crons appear to bypass the disk path, but this is unverified for multi-thread T3 and is a plausible source of "works with one thread, fails with three."

                              Safety: a tool the model can call to schedule itself is a tool it can abuse. Today, unbounded in full-access — 50 jobs, 7-day expiry, no approval. Phase 1d's default-off CLAUDE_CODE_DISABLE_CRON toggle closes the CronCreate half; ScheduleWakeup self-permits and cannot be closed by canUseTool — only by budget and by Loop Watch's outer caps. Do not ship Phase 1 claiming the hole is fully closed; it is bounded, not closed.

                              Provider fragmentation. Phase 1 is Claude-only by construction. On codex/cursor/grok/opencode threads the pill must render nothing at all — not a disabled control, not "unavailable". Phase 2's wake_me is the cure, and until it lands "self-paced loop" means something different per provider. There is no capability flag to express this: ProviderAdapterCapabilities (apps/server/src/provider/Services/ProviderAdapter.ts:28) has exactly one field, sessionModelSwitch, and all five adapters set it identically. Phase 1 must therefore branch on driver kind — the same hardcoded-allowlist smell composerSteering.logic.ts:13-43 already documents and apologises for.

                              Parallel-paths hazard (the fork's known failure mode). Upstream pingdotgg#3164 is 🚧 In Progress and PR pingdotgg#3638 already merged agent-facing schedule_task tools onto the orchestrator-v2 stack. A fork-local scheduling path that duplicates that capability will silently bypass whatever guards upstream ships with it. Phase 1 is safe here — it only observes. Phase 2 is exactly the hazard, which is the strongest reason it is deferred. Re-check docs/t3x/SEAMS.md and pingdotgg#2829's status at every sync.

                              Interaction with #39. Auto-resume already cancels as user-took-over on any new user message. A cron-fired turn injects a meta prompt — if that increments newestUserMessageId, it will trip the same false cancellation #39 describes, from a source no human produced. Verify against autoResume/guards.ts:130-131 before Phase 1 ships; this may be the cheapest concrete motivation to fix #39 first.

                              Examples or references

                              Related issues — this is adjacent to, not a duplicate of, several open items

                              A full duplicate sweep was run across all 1,615 upstream pingdotgg/t3code issues (open + closed, matching the search API total_count) and all 21 fork issues, plus a gh search prs pass. Upstream Discussions are disabled, so issues are the complete surface. Overlaps found:

                              Upstream — scheduling (the closest cluster):

                              Upstream — app-native tools:

                              Fork:

                              Evidence index

                              Claude Agent SDK 0.3.170 — types (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.170_*/node_modules/@anthropic-ai/claude-agent-sdk/):

                              • sdk-tools.d.ts:9-40CronCreateInput | CronDeleteInput | CronListInput | ScheduleWakeupInput in ToolInputSchemas
                              • sdk-tools.d.ts:2324-2333delaySeconds … Clamped to [60, 3600] by the runtime
                              • sdk.d.ts:6140-6142, :6181-6183session_crons?: SessionCronSummary[] on StopHookInput / SubagentStopHookInput
                              • sdk.d.ts:4204-4222SessionCronSummary
                              • sdk.d.ts:821 — 30 HookEvent values; sdk.d.ts:1486hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>
                              • sdk.d.ts:1648maxBudgetUsd, :1656taskBudget — both unset in this repo
                              • sdk.d.ts:485, :487-506, :6288-6292createSdkMcpServer, CreateSdkMcpServerOptions, tool()

                              Platform binary (node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.170/.../claude, manifest.json"version": "2.1.170", 222,102,816 bytes) — string counts re-verified this session: CronCreate 21, ScheduleWakeup 8, tengu_kairos_cron 5, tengu_kairos_loop_dynamic 2, scheduled_tasks.json 23.

                              • Registry: function TQ(){return[…,…m$3,MVK,…]}; m$3=[CronCreateTool, CronDeleteTool, CronListTool], MVK=ScheduleWakeupTool
                              • Nz3=a9({name:TW,…isEnabled(){return hS()}…}); function hS(){return !__(process.env.CLAUDE_CODE_DISABLE_CRON) && eE("tengu_kairos_cron",!0,…)}; var TW="CronCreate"
                              • function zTH(){return j_("tengu_kairos_loop_dynamic",!1)}; ScheduleWakeupTool.call: if(!zTH())return OsH("gate_off"),…
                              • Gate reader: function j_(H,_){… let O=E_().cachedGrowthBookFeatures?.[H]; return O!==void 0?O:_}
                              • print.ts scheduler (offset ~28151166):let M8=null; if(Tc4.isKairosCronEnabled()) M8=NDT.createCronScheduler({onFire:(u_)=>{if(G)return; let G6=yDT.resolveLoopDefaultFire(u_); Ij({mode:"prompt",value:G6,uuid:…,priority:"later",isMeta:!0,workload:wlH}), t6("cron_fire"), _6()}, isLoading:()=>D||G, …}), M8.start();
                              • REPL-only second consumer: function cjT({isLoading,assistantMode,setMessages}) exported as useScheduledTasks — the source of the "REPL-only" misreading
                              • ScheduleWakeupTool.checkPermissions{behavior:"allow",updatedInput:H}; CronCreateTool has validateInput, nocheckPermissions
                              • var lyK=50Too many scheduled jobs (max 50). Cancel one first.; CronCreateTool.call: let O=K&&YTH() where YTH() reads tengu_kairos_cron_durable
                              • All four tools are shouldDefer:!0 (tool-search deferred) — independently confirmed by their presence in this session's own deferred-tool list

                              Gate cache/Users/rajdholakia/.claude.jsoncachedGrowthBookFeatures (442 entries, re-read this session): tengu_kairos_cron = true, tengu_kairos_loop_dynamic = true, tengu_kairos_cron_durable = false.

                              T3 Code (paths relative to repo root, line numbers verified against main @ 4b126c02f):

                              • apps/server/src/provider/Layers/ClaudeAdapter.ts:3524-3562 — full queryOptions; :3549-3561mcpServers spread; :3181-3189 unbounded prompt queue → Stream.toAsyncIterable; :2468-2507 synthetic-turn auto-start + raw.method: "claude/synthetic-turn-start" at :2504; :2548-2563handleResultMessagecompleteTurn; :3373-3378 full-access auto-allow; :3380-3436 unhandled tools → request.opened; :3512-3517 runtimeMode → permissionMode map; :884-888CLAUDE_SETTING_SOURCES = ["user","project","local"]
                              • apps/server/src/provider/Drivers/ClaudeHome.ts:17-36makeClaudeEnvironment, CLAUDE_CONFIG_DIR relocation
                              • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17-18 — 30 min / 5 min; :57-71 — idle test + activeTurnId skip; :73+stopSession, reason: "inactivity_threshold"
                              • apps/server/src/mcp/McpHttpServer.ts:206-225 — toolkit registration (McpServer.toolkit(...), PreviewToolkitRegistrationLive)
                              • apps/server/src/mcp/McpInvocationContext.ts:10export type McpCapability = "preview";; :12-19McpInvocationScope; :26-39requireMcpCapability failing with PreviewAutomationUnavailableError
                              • apps/server/src/mcp/McpSessionRegistry.ts:131capabilities: new Set(["preview"])
                              • appsis/server/src/t3x/index.ts:66-74T3xLayerLive; T3xRoutesLive below it (churn 0)
                              • apps/server/src/t3x/webPush/http.ts:8-10 — the raw-route rationale comment
                              • apps/server/src/t3x/autoResume/Reactor.ts:109engine.dispatch({type:"thread.turn.start", …}), the precedent for T3-side re-invocation
                              • apps/server/src/provider/Services/ProviderAdapter.ts:28ProviderAdapterCapabilities (one field)
                              • apps/web/src/outbox/composerSteering.logic.ts:13,43 — the hardcoded driver allowlist and its own apology
                              • apps/server/src/checkpointing/Utils.ts:12-27resolveThreadWorkspaceCwd, worktree-first (the sentinel-file hazard)
                              • docs/t3x/SEAMS.md:5 (34 rows, +1616/-187), :17 (aggregator rule), :21 (row-35 tripwire), :23-28 (self-reference rule), :59 (server.ts row, churn 29), :107 (ClaudeAdapter.ts on the watch list)
                              • docs/t3x/loop/DESIGN.md §1 (trigger), §5 (guard table, guards fix(t3x): auto-resume never fired — 'thread-advanced' false cancellation on settled turns #9/fix(server): treat slow az --version as present, not missing (#4) #10), §6 (auto-resume coexistence) — branch t3x/loop-supervisor, commit cbb3a1373

                              Churn, measured this session with MB=64bf01619; MBTS=$(git show -s --format=%ct $MB); git log --oneline --since="@$((MBTS-60*86400))" $MB -- <path> | wc -l:
                              ClaudeAdapter.ts12 · McpSessionRegistry.ts7 · McpHttpServer.ts6 · McpInvocationContext.ts3 · McpProviderSession.ts1 · ProviderSessionReaper.ts1 · BetaSettingsPanel.tsx3 · server.ts29 · t3x/index.ts0


                              Duplicate search performed before filing

                              Exhaustive, not sampled. The full upstream title corpus was dumped locally (gh issue list --repo pingdotgg/t3code --state all --limit 6000 → 1,615 rows, matching gh api search/issues … --jq '.total_count' → 1,615) and grepped against ~80 term variants; body-level full-text via gh search issues per concept; plus a gh search prs sweep. All 21 radroid/t3code issues checked (re-listed this session). Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete surface.

                              Found — scheduling is heavily claimed upstream.#3164 is the canonical open Automations & Triggers issue, labelled 🚧 In Progress, and it has already absorbed #437 and #1390 as closed-duplicates. #3624 is a narrower one-shot scheduled prompt. #5123 proposes the wake primitive but explicitly excludes a scheduler. Most decisively, PR pingdotgg#3638 is already merged — onto t3code/codex-turn-mapping, not main — shipping schedule_task / list_scheduled_tasks / update_scheduled_task / delete_scheduled_task as model-callable MCP tools gated behind orchestrator-v2 (#2829, still open against main). #4266 + PR #5003 are the durable-waitpoint analogue; PR #4262 (t3.wait, closed unmerged) is the prior art for host-managed tools.

                              Conclusion: this must not be filed upstream — it would be closed as a duplicate of pingdotgg#3164, the same way pingdotgg#437 and pingdotgg#1390 were. Filed on the fork, the non-duplicate core is narrow and stated as the whole pitch: (a) the existing shipped binary already contains a working scheduler reachable from T3 with zero code changes, which no issue in either repo observes; (b) session_crons via options.hooks as read-only observability — hooks is set nowhere in this repo, 30 events, zero subscriptions; (c) the bounding/safety policy for full-access self-arming; (d) composition with the fork's own Loop Watch (#38). Phase 2 (wake_me toolkit) does overlap PR pingdotgg#3638 and pingdotgg#4266, which is exactly why it is deferred rather than proposed for v1 — building it fork-local before pingdotgg#2829 lands is the fork's known "parallel paths" hazard.

                              Fork: no duplicate. #38 (Loop Watch) is complementary and its body puts cron-scheduled thread creation explicitly out of scope, so it does not block this; #39 is an auto-resume bug that this feature may aggravate. Zero fork issues on scheduling, hooks, or MCP toolkits.

                              Not searched: upstream PRs were swept for scheduling/subagent terms but not exhaustively for hooks / session_crons specifically — if an upstream PR already subscribes options.hooks, Phase 1's seam row could be avoided entirely by waiting for it. Worth a 5-minute gh search prs --repo pingdotgg/t3code "hooks" before opening the implementation PR.

                              Contribution

                              • I would be open to helping implement this.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                enhancementNew feature or request

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions