[Feature]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

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

Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

What exists today, and where it stops:

  • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
  • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
  • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
  • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

Proposed solution

A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

Concrete detection design:

  • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
  • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
  • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
  • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
  • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

2. Per-project enablement — a file in the repo, not a settings row

.t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

{
"enabled": true,
"repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
"labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
"maxOpenAtOnce": 3,
"openDraftPr": false,
"ignoreAuthors": []
}

This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

  1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
  2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
  3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
  4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

4. Triage — one turn, one prompt, in the thread

Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

  • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
  • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

5. Safety rails

  • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
  • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
  • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
  • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
  • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
  • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

6. Multi-project

One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

8. Explicitly unverified, with the experiment that settles each

  • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
  • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
  • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
  • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
  • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

Why this matters

Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

Concretely:

  • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
  • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
  • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
  • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
  • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

Smallest useful scope

v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

Ship apps/server/src/t3x/maintainer/ with:

  1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
  2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
  3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
  4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
  5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
  6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

Alternatives considered

Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

Risks or tradeoffs

Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

  • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
  • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
  • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
  • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
  • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

Behavioural and product risks

Examples or references

Upstream issues and PRs (pingdotgg/t3code)

Fork issues (radroid/t3code)

Code, with line references (paths relative to repo root)

Autonomous dispatch, the pattern to copy:

  • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
  • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
  • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
  • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
  • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
  • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

Registration and layer wiring:

  • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
  • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
  • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
  • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
  • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

GitHub access:

  • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
  • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
  • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

Thread + worktree creation:

  • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
  • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
  • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

Projects and queries:

  • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
  • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
  • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

Safety and limits:

  • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
  • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
  • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
  • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

Seam discipline:

  • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
  • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
  • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

Duplicate search performed before filing

Searched exhaustively across both repos before filing.

Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

Real overlaps, disclosed and cross-referenced in the References section:

Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

      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

      Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

      What exists today, and where it stops:

      • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
      • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
      • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
      • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

      So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

      Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

      Proposed solution

      A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

      1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

      Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

      But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

      Concrete detection design:

      • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
      • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
      • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
      • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
      • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

      Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

      2. Per-project enablement — a file in the repo, not a settings row

      .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

      {
      "enabled": true,
      "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
      "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
      "maxOpenAtOnce": 3,
      "openDraftPr": false,
      "ignoreAuthors": []
      }

      This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

      Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

      3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

      For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

      1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
      2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
      3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
      4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

      This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

      GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

      4. Triage — one turn, one prompt, in the thread

      Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

      Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

      • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
      • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

      The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

      5. Safety rails

      • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
      • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
      • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
      • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
      • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
      • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

      6. Multi-project

      One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

      7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

      Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

      The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

      That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

      8. Explicitly unverified, with the experiment that settles each

      • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
      • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
      • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
      • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
      • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

      Why this matters

      Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

      Concretely:

      • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
      • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
      • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
      • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
      • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

      Smallest useful scope

      v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

      Ship apps/server/src/t3x/maintainer/ with:

      1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
      2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
      3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
      4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
      5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
      6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

      Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

      v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

      v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

      Alternatives considered

      Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

      A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

      Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

      gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

      Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

      A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

      Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

      Risks or tradeoffs

      Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

      docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

      • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
      • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
      • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
      • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
      • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

      Behavioural and product risks

      Examples or references

      Upstream issues and PRs (pingdotgg/t3code)

      Fork issues (radroid/t3code)

      Code, with line references (paths relative to repo root)

      Autonomous dispatch, the pattern to copy:

      • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
      • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
      • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
      • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
      • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
      • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

      Registration and layer wiring:

      • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
      • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
      • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
      • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
      • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

      GitHub access:

      • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
      • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
      • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

      Thread + worktree creation:

      • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
      • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
      • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

      Projects and queries:

      • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
      • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
      • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

      Safety and limits:

      • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
      • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
      • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
      • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

      Seam discipline:

      • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
      • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
      • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

      Duplicate search performed before filing

      Searched exhaustively across both repos before filing.

      Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

      Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

      Real overlaps, disclosed and cross-referenced in the References section:

      Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

      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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

          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

          Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

          What exists today, and where it stops:

          • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
          • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
          • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
          • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

          So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

          Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

          Proposed solution

          A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

          1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

          Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

          But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

          Concrete detection design:

          • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
          • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
          • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
          • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
          • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

          Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

          2. Per-project enablement — a file in the repo, not a settings row

          .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

          {
          "enabled": true,
          "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
          "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
          "maxOpenAtOnce": 3,
          "openDraftPr": false,
          "ignoreAuthors": []
          }

          This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

          Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

          3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

          For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

          1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
          2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
          3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
          4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

          This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

          GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

          4. Triage — one turn, one prompt, in the thread

          Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

          Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

          • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
          • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

          The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

          5. Safety rails

          • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
          • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
          • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
          • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
          • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
          • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

          6. Multi-project

          One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

          7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

          Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

          The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

          That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

          8. Explicitly unverified, with the experiment that settles each

          • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
          • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
          • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
          • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
          • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

          Why this matters

          Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

          Concretely:

          • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
          • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
          • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
          • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
          • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

          Smallest useful scope

          v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

          Ship apps/server/src/t3x/maintainer/ with:

          1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
          2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
          3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
          4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
          5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
          6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

          Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

          v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

          v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

          Alternatives considered

          Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

          A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

          Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

          gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

          Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

          A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

          Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

          Risks or tradeoffs

          Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

          docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

          • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
          • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
          • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
          • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
          • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

          Behavioural and product risks

          Examples or references

          Upstream issues and PRs (pingdotgg/t3code)

          Fork issues (radroid/t3code)

          Code, with line references (paths relative to repo root)

          Autonomous dispatch, the pattern to copy:

          • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
          • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
          • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
          • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
          • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
          • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

          Registration and layer wiring:

          • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
          • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
          • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
          • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
          • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

          GitHub access:

          • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
          • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
          • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

          Thread + worktree creation:

          • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
          • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
          • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

          Projects and queries:

          • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
          • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
          • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

          Safety and limits:

          • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
          • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
          • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
          • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

          Seam discipline:

          • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
          • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
          • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

          Duplicate search performed before filing

          Searched exhaustively across both repos before filing.

          Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

          Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

          Real overlaps, disclosed and cross-referenced in the References section:

          Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

          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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

              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

              Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

              What exists today, and where it stops:

              • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
              • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
              • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
              • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

              So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

              Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

              Proposed solution

              A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

              1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

              Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

              But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

              Concrete detection design:

              • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
              • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
              • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
              • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
              • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

              Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

              2. Per-project enablement — a file in the repo, not a settings row

              .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

              {
              "enabled": true,
              "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
              "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
              "maxOpenAtOnce": 3,
              "openDraftPr": false,
              "ignoreAuthors": []
              }

              This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

              Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

              3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

              For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

              1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
              2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
              3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
              4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

              This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

              GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

              4. Triage — one turn, one prompt, in the thread

              Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

              Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

              • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
              • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

              The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

              5. Safety rails

              • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
              • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
              • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
              • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
              • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
              • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

              6. Multi-project

              One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

              7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

              Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

              The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

              That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

              8. Explicitly unverified, with the experiment that settles each

              • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
              • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
              • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
              • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
              • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

              Why this matters

              Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

              Concretely:

              • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
              • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
              • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
              • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
              • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

              Smallest useful scope

              v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

              Ship apps/server/src/t3x/maintainer/ with:

              1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
              2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
              3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
              4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
              5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
              6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

              Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

              v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

              v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

              Alternatives considered

              Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

              A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

              Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

              gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

              Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

              A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

              Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

              Risks or tradeoffs

              Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

              docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

              • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
              • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
              • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
              • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
              • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

              Behavioural and product risks

              Examples or references

              Upstream issues and PRs (pingdotgg/t3code)

              Fork issues (radroid/t3code)

              Code, with line references (paths relative to repo root)

              Autonomous dispatch, the pattern to copy:

              • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
              • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
              • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
              • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
              • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
              • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

              Registration and layer wiring:

              • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
              • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
              • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
              • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
              • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

              GitHub access:

              • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
              • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
              • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

              Thread + worktree creation:

              • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
              • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
              • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

              Projects and queries:

              • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
              • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
              • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

              Safety and limits:

              • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
              • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
              • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
              • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

              Seam discipline:

              • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
              • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
              • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

              Duplicate search performed before filing

              Searched exhaustively across both repos before filing.

              Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

              Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

              Real overlaps, disclosed and cross-referenced in the References section:

              Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

              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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

                  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

                  Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

                  What exists today, and where it stops:

                  • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
                  • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
                  • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
                  • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

                  So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

                  Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

                  Proposed solution

                  A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

                  1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

                  Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

                  But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

                  Concrete detection design:

                  • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
                  • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
                  • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
                  • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
                  • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

                  Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

                  2. Per-project enablement — a file in the repo, not a settings row

                  .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

                  {
                  "enabled": true,
                  "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
                  "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
                  "maxOpenAtOnce": 3,
                  "openDraftPr": false,
                  "ignoreAuthors": []
                  }

                  This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

                  Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

                  3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

                  For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

                  1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
                  2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
                  3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
                  4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

                  This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

                  GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

                  4. Triage — one turn, one prompt, in the thread

                  Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

                  Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

                  • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
                  • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

                  The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

                  5. Safety rails

                  • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
                  • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
                  • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
                  • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
                  • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
                  • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

                  6. Multi-project

                  One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

                  7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

                  Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

                  The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

                  That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

                  8. Explicitly unverified, with the experiment that settles each

                  • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
                  • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
                  • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
                  • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
                  • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

                  Why this matters

                  Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

                  Concretely:

                  • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
                  • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
                  • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
                  • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
                  • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

                  Smallest useful scope

                  v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

                  Ship apps/server/src/t3x/maintainer/ with:

                  1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
                  2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
                  3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
                  4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
                  5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
                  6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

                  Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

                  v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

                  v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

                  Alternatives considered

                  Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

                  A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

                  Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

                  gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

                  Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

                  A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

                  Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

                  Risks or tradeoffs

                  Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

                  docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

                  • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
                  • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
                  • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
                  • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
                  • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

                  Behavioural and product risks

                  Examples or references

                  Upstream issues and PRs (pingdotgg/t3code)

                  Fork issues (radroid/t3code)

                  Code, with line references (paths relative to repo root)

                  Autonomous dispatch, the pattern to copy:

                  • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
                  • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
                  • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
                  • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
                  • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
                  • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

                  Registration and layer wiring:

                  • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
                  • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
                  • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
                  • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
                  • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

                  GitHub access:

                  • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
                  • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
                  • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

                  Thread + worktree creation:

                  • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
                  • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
                  • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

                  Projects and queries:

                  • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
                  • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
                  • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

                  Safety and limits:

                  • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
                  • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
                  • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
                  • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

                  Seam discipline:

                  • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
                  • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
                  • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

                  Duplicate search performed before filing

                  Searched exhaustively across both repos before filing.

                  Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

                  Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

                  Real overlaps, disclosed and cross-referenced in the References section:

                  Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

                  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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

                      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

                      Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

                      What exists today, and where it stops:

                      • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
                      • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
                      • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
                      • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

                      So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

                      Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

                      Proposed solution

                      A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

                      1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

                      Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

                      But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

                      Concrete detection design:

                      • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
                      • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
                      • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
                      • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
                      • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

                      Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

                      2. Per-project enablement — a file in the repo, not a settings row

                      .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

                      {
                      "enabled": true,
                      "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
                      "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
                      "maxOpenAtOnce": 3,
                      "openDraftPr": false,
                      "ignoreAuthors": []
                      }

                      This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

                      Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

                      3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

                      For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

                      1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
                      2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
                      3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
                      4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

                      This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

                      GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

                      4. Triage — one turn, one prompt, in the thread

                      Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

                      Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

                      • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
                      • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

                      The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

                      5. Safety rails

                      • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
                      • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
                      • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
                      • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
                      • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
                      • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

                      6. Multi-project

                      One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

                      7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

                      Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

                      The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

                      That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

                      8. Explicitly unverified, with the experiment that settles each

                      • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
                      • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
                      • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
                      • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
                      • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

                      Why this matters

                      Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

                      Concretely:

                      • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
                      • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
                      • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
                      • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
                      • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

                      Smallest useful scope

                      v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

                      Ship apps/server/src/t3x/maintainer/ with:

                      1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
                      2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
                      3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
                      4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
                      5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
                      6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

                      Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

                      v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

                      v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

                      Alternatives considered

                      Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

                      A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

                      Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

                      gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

                      Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

                      A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

                      Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

                      Risks or tradeoffs

                      Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

                      docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

                      • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
                      • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
                      • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
                      • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
                      • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

                      Behavioural and product risks

                      Examples or references

                      Upstream issues and PRs (pingdotgg/t3code)

                      Fork issues (radroid/t3code)

                      Code, with line references (paths relative to repo root)

                      Autonomous dispatch, the pattern to copy:

                      • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
                      • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
                      • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
                      • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
                      • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
                      • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

                      Registration and layer wiring:

                      • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
                      • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
                      • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
                      • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
                      • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

                      GitHub access:

                      • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
                      • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
                      • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

                      Thread + worktree creation:

                      • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
                      • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
                      • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

                      Projects and queries:

                      • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
                      • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
                      • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

                      Safety and limits:

                      • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
                      • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
                      • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
                      • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

                      Seam discipline:

                      • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
                      • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
                      • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

                      Duplicate search performed before filing

                      Searched exhaustively across both repos before filing.

                      Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

                      Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

                      Real overlaps, disclosed and cross-referenced in the References section:

                      Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

                      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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

                          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

                          Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

                          What exists today, and where it stops:

                          • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
                          • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
                          • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
                          • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

                          So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

                          Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

                          Proposed solution

                          A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

                          1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

                          Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

                          But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

                          Concrete detection design:

                          • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
                          • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
                          • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
                          • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
                          • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

                          Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

                          2. Per-project enablement — a file in the repo, not a settings row

                          .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

                          {
                          "enabled": true,
                          "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
                          "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
                          "maxOpenAtOnce": 3,
                          "openDraftPr": false,
                          "ignoreAuthors": []
                          }

                          This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

                          Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

                          3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

                          For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

                          1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
                          2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
                          3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
                          4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

                          This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

                          GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

                          4. Triage — one turn, one prompt, in the thread

                          Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

                          Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

                          • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
                          • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

                          The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

                          5. Safety rails

                          • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
                          • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
                          • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
                          • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
                          • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
                          • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

                          6. Multi-project

                          One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

                          7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

                          Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

                          The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

                          That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

                          8. Explicitly unverified, with the experiment that settles each

                          • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
                          • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
                          • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
                          • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
                          • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

                          Why this matters

                          Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

                          Concretely:

                          • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
                          • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
                          • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
                          • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
                          • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

                          Smallest useful scope

                          v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

                          Ship apps/server/src/t3x/maintainer/ with:

                          1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
                          2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
                          3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
                          4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
                          5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
                          6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

                          Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

                          v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

                          v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

                          Alternatives considered

                          Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

                          A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

                          Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

                          gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

                          Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

                          A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

                          Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

                          Risks or tradeoffs

                          Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

                          docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

                          • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
                          • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
                          • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
                          • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
                          • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

                          Behavioural and product risks

                          Examples or references

                          Upstream issues and PRs (pingdotgg/t3code)

                          Fork issues (radroid/t3code)

                          Code, with line references (paths relative to repo root)

                          Autonomous dispatch, the pattern to copy:

                          • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
                          • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
                          • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
                          • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
                          • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
                          • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

                          Registration and layer wiring:

                          • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
                          • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
                          • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
                          • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
                          • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

                          GitHub access:

                          • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
                          • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
                          • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

                          Thread + worktree creation:

                          • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
                          • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
                          • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

                          Projects and queries:

                          • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
                          • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
                          • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

                          Safety and limits:

                          • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
                          • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
                          • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
                          • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

                          Seam discipline:

                          • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
                          • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
                          • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

                          Duplicate search performed before filing

                          Searched exhaustively across both repos before filing.

                          Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

                          Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

                          Real overlaps, disclosed and cross-referenced in the References section:

                          Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

                          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]: maintainer agent — work a repo's issue queue automatically (triage → plan or draft PR → human approval) #44

                              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

                              Maintaining several repos means the same manual loop, per repo, forever: someone opens an issue, I read it, I decide whether it is a five-minute fix or a design question, I open T3 Code, I create a thread, I paste the issue text in, I create a worktree, and only then does any agent work start. The mechanical part of that loop is the part I do dozens of times and the part T3 Code is already built to do — but nothing in T3 Code will start it for me.

                              What exists today, and where it stops:

                              • T3 Code can already dispatch a turn with no human in the loop.apps/server/src/t3x/autoResume/Reactor.ts:109 dispatches thread.turn.start through OrchestrationEngineService.dispatch and the comment above it says it is "byte-for-byte the path a keystroke produces". That is the whole autonomous-dispatch primitive, and it is already in the fork seam. But it only ever resumes an existing thread — it never creates one, and it is triggered by a Claude rate-limit event, not by anything outside the app.
                              • T3 Code can already talk to GitHub.apps/server/src/sourceControl/GitHubCli.ts:203 exposes a general execute({ cwd, args }) escape hatch onto the authenticated gh CLI, plus typed listOpenPullRequests / createPullRequest / getDefaultBranch. Nothing reads issues.
                              • T3 Code can already stand up an isolated workspace per unit of work.thread.turn.start carries a bootstrap that creates a thread, prepares a git worktree and runs the setup script (packages/contracts/src/orchestration.ts:671). But that bootstrap is not implemented in the orchestration engine — it lives inline in the WebSocket transport at apps/server/src/ws.ts:749 (dispatchBootstrapTurnStart). A server-side supervisor that calls engine.dispatch directly, the way AutoResumeReactor does, gets no thread creation, no worktree and no setup script. That gap is the single biggest reason this feature does not already exist as a 50-line reactor.
                              • Multi-project state is already queryable.ProjectionSnapshotQuery.getShellSnapshot() (apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83) returns every project with its workspaceRoot and an optional repositoryIdentity carrying owner / name (packages/contracts/src/environment.ts:87-95). Nothing walks it looking for work to do.

                              So every piece is present and none of them are connected. The result is that T3 Code is an excellent place to do maintenance work and a useless place to receive it.

                              Secondary problem: the routing decision is what actually costs me time, and it is not a decision an agent should make silently. Roughly a third of inbound issues are "this is a config mistake / won't do / needs a design conversation", a third are "here is a clear bug with a clear fix", and a third are somewhere in between. Today an unattended agent pointed at an issue queue would treat all three identically and start writing code for the ones that should have produced a paragraph instead.

                              Proposed solution

                              A fork-local server feature at apps/server/src/t3x/maintainer/, registered through the existing aggregator in apps/server/src/t3x/index.ts (T3xLayerLive, and later T3xRoutesLive). Same shape as autoResume/: a self-starting scoped fiber, a durable JSON state file in config.stateDir, pure decision functions with unit tests, and zero new upstream-file edits.

                              1. Detection — poll, not webhook, and REST rather than gh's GraphQL paths

                              Webhooks are the wrong answer here. T3 Code's server runs on a laptop; there is no stable public ingress (Tailscale serve is per-user and not a deployment target), and a webhook receiver means a GitHub App, a secret, and a public URL. Polling with the credentials the user already has is the only design that works with zero setup — which is the bar autoResume set (apps/server/src/t3x/autoResume/config.ts:63 — "Config is read from env with safe defaults, so the feature works with zero setup").

                              But polling must be done carefully, because the fork's parent repo already has a rate-limit incident from exactly this: upstream pingdotgg#3581 documents VcsStatusBroadcaster.retainRemotePoller → … → GitHubCli.listChangeRequests → gh pr list … fanning out per retained worktree branch and driving GitHub GraphQL usage from ~184 to ~3,879 of 5,000 points while the app sat idle, which then broke an unrelated gh pr create. gh pr list --json and gh issue list --json both go through GraphQL.

                              Concrete detection design:

                              • One fiber for all projects, not one per project and emphatically not one per thread. Iterate getShellSnapshot().projects sequentially with a per-project stagger.
                              • REST, via the existing escape hatch: GitHubCli.execute({ cwd: project.workspaceRoot, args: ["api", "-H", "Accept: application/vnd.github+json", repos/${owner}/${name}/issues?state=open&sort=updated&direction=desc&per_page=50&since=${lastSeenIso}] }). REST has its own 5,000 req/hr budget, separate from the GraphQL points budget [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581 exhausted, and one request per project per interval is a rounding error against it.
                              • Default interval 5 minutes, T3X_MAINTAINER_POLL_MS override, jittered.
                              • Two gotchas that must be handled or the feature misbehaves on day one: (a) GitHub's REST /issues collection includes pull requests — every item carrying a pull_request key must be dropped; (b) since is updated_at, so an old issue that gets a new comment reappears — dedupe on issue number against durable state, never on the cursor alone.
                              • Cursor + dedupe live in a fork-owned JSON file (t3x-maintainer.json in config.stateDir), following apps/server/src/t3x/autoResume/state.ts exactly: SynchronizedRef + atomic write via writeFileStringAtomically, decode failure falls back to empty, and the module comment there states the reason to avoid a DB migration ("the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a ~single JSON file does fine").

                              Keep this behind one module boundary — detect.ts exporting something like pollProject(config): Effect<ReadonlyArray<DetectedIssue>>. See §7 for why that boundary is load-bearing.

                              2. Per-project enablement — a file in the repo, not a settings row

                              .t3x/maintainer.json at the project's workspaceRoot, mirroring the existing .t3x/resume-prompt.md convention (apps/server/src/t3x/autoResume/config.ts:88, RESUME_PROMPT_RELATIVE_PATH). Absent file = feature off for that project.

                              {
                              "enabled": true,
                              "repo": "owner/name", // default: project.repositoryIdentity.owner + .name"baseBranch": "main",
                              "labels": { "allow": ["bug"], "deny": ["wontfix", "discussion"] },
                              "maxOpenAtOnce": 3,
                              "openDraftPr": false,
                              "ignoreAuthors": []
                              }

                              This is deliberately not a T3 setting. packages/contracts/src/settings.ts is already a ledger row (+7/-2, churn 18, risk 162) and is a persisted schema — adding a field there costs a migration-shaped risk for a per-repo toggle. A repo-committed file also means "turn the maintainer agent off" is a commit with an author and a diff, which is the right audit story for a thing that opens branches.

                              Plus a global kill switch T3X_MAINTAINER_ENABLED (default false for v1 — unlike auto-resume, this one creates threads), resolved by a pure resolveConfig(env) exactly as autoResume/config.ts:63-73 does.

                              3. Thread creation — duplicate ws.ts's bootstrap, do not edit ws.ts

                              For each accepted issue, the reactor does what apps/server/src/ws.ts:891-933 does, but from inside t3x:

                              1. engine.dispatch({ type: "thread.create", … }) — schema at packages/contracts/src/orchestration.ts:554-568. projectId from the shell snapshot, title as #123 — <issue title> (a prefix the user and any later UI can filter on), modelSelection from project.defaultModelSelection, branch/worktreePath null for now.
                              2. gitWorkflow.fetchRemotegitWorkflow.resolveRemoteTrackingCommitgitWorkflow.createWorktree({ cwd: workspaceRoot, refName: <resolved base sha>, newRefName: "t3x/issue-123", baseRefName: config.baseBranch, path: null }) — the same three calls as ws.ts:908-930. GitWorkflowService is reachable from the t3x layer: T3xLayerLive is merged into ReactorLayerLive (apps/server/src/server.ts:224), and RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… Layer.provideMerge(VcsLayerLive) …) at server.ts:346-352, with GitWorkflowLayerLive inside VcsLayerLive at server.ts:287.
                              3. engine.dispatch({ type: "thread.meta.update", branch, worktreePath }).
                              4. engine.dispatch({ type: "thread.turn.start", … }) — copy autoResume/Reactor.ts:96-119 verbatim, only the prompt text differs.

                              This duplication must be registered as a logic mirror in docs/t3x/SEAMS.md under "Logic mirrors (semantic dependencies, not code seams)" — the same treatment autoResume/http.ts's authenticateWithOperateScope already gets for mirroring http.ts's private authenticateRawRouteWithScope. Editing ws.ts to export the bootstrap instead would add a new ledger row on a hot upstream file, which the tripwire in SEAMS.md:21 forbids.

                              GitHubCli needs one extra step: GitHubCli.layer is Layer.provided into SourceControlProviderRegistry.layer (server.ts:248-251), not merged, so it is not in the reactor's environment. t3x must provide it itself inside t3x/index.ts. Its only dependency, VcsProcess, is merged at the outermost runtime layer (server.ts:632), so this resolves without touching server.ts.

                              4. Triage — one turn, one prompt, in the thread

                              Do not build a separate classifier service. Structured classification would mean extending TextGenerationService, which is a closed four-operation interface (apps/server/src/textGeneration/TextGeneration.ts:74) routed per provider instance — a fifth operation means five implementations, and three of the five providers have no native schema mode and fall back to prompt-instructed JSON anyway.

                              Instead, the first turn's prompt is the triage, and the routing is expressed as the thread's mode:

                              • Complex / ambiguous / impossible / won't-do → the thread is created with interactionMode: "plan" (packages/contracts/src/orchestration.ts:126). The turn produces an OrchestrationProposedPlan (orchestration.ts:244-254), which already renders in the plan surface and already has a one-click "implement in a new thread" path via implementationThreadId. Nothing touches the working tree.
                              • EasyinteractionMode: "default", runtimeMode: "auto-accept-edits", worktree prepared, and the prompt instructs: implement, run the project's checks, commit on the branch, stop. Do not push. Do not open a PR.

                              The cheap way to make the mode decision without a second model call: run the classification as a plan-mode turn for every issue, and have the prompt end with a machine-readable verdict line the reactor greps for (T3X-TRIAGE: easy|complex|declined). On easy, the reactor dispatches a second turn into the same thread after flipping interactionMode to default. This costs one extra turn per easy issue and buys a human-readable rationale in the transcript for every routing decision, including the wrong ones. UNVERIFIED that a thread.meta.update-style interaction-mode flip mid-thread is supported by the decider — see §8.

                              5. Safety rails

                              • Never full-access.full-access maps to Claude's bypassPermissions and T3's canUseTool auto-allows every tool (apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517, :3372-3378). An unattended thread nobody asked for must not run in the mode where nothing can be refused. auto-accept-edits lets file edits through while commands still surface as approvals.
                              • Always a worktree, always a branch.t3x/issue-<n>, created off the resolved remote base. The reactor never calls switchRef and never operates in the project checkout.
                              • Never push, never open a PR in v1.openDraftPr defaults to false. When enabled in v1.1 it uses the existing typed GitHubCli.createPullRequest (GitHubCli.ts:232) and the PR is a draft. Never merge — the reactor has no merge path at all, by construction.
                              • Caps, durable.maxOpenAtOnce (default 3, counted across all projects), maxNewThreadsPerHour, and per-issue dedupe by number so a server restart cannot re-file. Mirror autoResume's maxResumesPer24h (config.ts:32) and its fired-history retention window (state.ts:25).
                              • A visible trail. Every decision — detected, accepted, skipped-by-label, capped, triaged-as-X — appended via thread.activity.append, copying autoResume/Reactor.ts:64-95 including its best-effort catchCause so a timeline failure never fails the run. A thread that appears on its own must explain itself.
                              • Fan-out ceiling is real, not theoretical. Provider session startup is serialized through a single DrainableWorker (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323), so N new threads = N sequential CLI spawns before any of them runs. And ProviderSessionReaper reaps idle sessions after 30 minutes by default (apps/server/src/provider/Layers/ProviderSessionReaper.ts:17), so a parked maintainer thread loses its process silently. Both argue for a small maxOpenAtOnce.

                              6. Multi-project

                              One supervisor fiber iterating getShellSnapshot().projects. Use getShellSnapshot(), not getSnapshot()docs/t3x/SEAMS.md's logic-mirror table records a live open risk that getSnapshot() hydrates every message and activity payload and "has OOM-killed servers" upstream. Each project is independently enabled by its own .t3x/maintainer.json, so adding a repo is a commit in that repo and nothing else.

                              7. Relationship to upstream pingdotgg#3164 — this is a consumer, not a competitor

                              Upstream pingdotgg#3164 (Automations & Triggers, labeled 🚧 In Progress) owns the trigger transport: cron schedules plus GitHub / GitLab / Sentry / Linear triggers, project-scoped, user-configured in the UI. It has already absorbed pingdotgg#437 and pingdotgg#1390 as duplicates. This issue is explicitly not a competing trigger system.

                              The split: pingdotgg#3164 answers "how does something outside T3 Code start a turn?". This issue answers "given an inbound issue, what should the agent do with it, and what must a human still approve?" — the triage taxonomy, the plan-vs-implement routing, the never-push/never-merge contract, the per-repo enablement file, the caps.

                              That is why detection is isolated behind detect.ts. If pingdotgg#3164 lands with a GitHub issue trigger, detect.ts is deleted and replaced by a subscription to that trigger; everything in §3, §4 and §5 survives unchanged. The same applies to pingdotgg#4266 / PR pingdotgg#5003 (durable local GitHub waitpoints) — those wait on a condition for one known PR, which is a different primitive from draining a queue, but they would be the right mechanism for "wake this thread when CI goes green on the branch it just pushed", a natural v2.

                              8. Explicitly unverified, with the experiment that settles each

                              • Does REST polling stay cheap in practice? Claimed on the basis that [Bug]: Background PR status polling via gh pr list drains GitHub GraphQL rate limit pingdotgg/t3code#3581's incident was GraphQL and REST has a separate budget. Experiment: gh api -i rate_limit, then 20 gh api repos/OWNER/NAME/issues?since=… calls, then gh api -i rate_limit again; read the x-ratelimit-used delta for both the core and graphql resources. Also test whether gh api --cache 300s (flag confirmed present in gh 2.96.0) returns 304-backed responses that do not decrement core.
                              • Is GitHubCli constructible from inside t3x? The layer analysis in §3 says yes (only needs VcsProcess, merged at server.ts:632). Experiment: add GitHubCli.layer to T3xLayerLive and run the server typecheck — an unsatisfied requirement will surface as a type error, which is exactly the "must never widen an upstream signature" property t3x/index.ts documents.
                              • Does an engine-dispatched thread.create (bypassing ws.ts) produce a thread that shows up normally?Experiment: a test modeled on apps/server/src/t3x/autoResume/Reactor.test.ts that dispatches thread.create + thread.meta.update + thread.turn.start and asserts the thread appears in getShellSnapshot() with the right branch and worktree.
                              • Can interactionMode be flipped mid-thread by a fork-side dispatch? The §4 two-turn design depends on it. Experiment: grep apps/server/src/orchestration/decider.ts for the thread.turn.start interaction-mode handling and write a decider test. If it cannot, fall back to: triage in a throwaway plan thread, then create a second implementation thread for easy — more threads, same safety.
                              • Cost. One agent thread per inbound issue, unattended. Nobody has measured what a week of a busy repo costs. maxNewThreadsPerHour is the crude guard; a real per-thread budget does not exist in T3 today.

                              Why this matters

                              Maintainers with several repos pay a fixed per-issue tax that is almost entirely mechanical: read, classify, decide, create a thread, create a worktree, paste context. This removes the mechanical part and leaves exactly the two decisions that need a human — "was this triaged correctly?" and "should this land?" — as explicit, reviewable artifacts (a proposed plan, or a branch with a diff).

                              Concretely:

                              • The queue gets worked while I am asleep, and nothing lands while I am asleep. By the time I look, complex issues already have a plan I can accept or throw away, and easy issues already have a branch with a diff and passing checks. Neither state is irreversible.
                              • The routing decision becomes visible. Every issue gets a written rationale in a thread timeline before any code is written. That is strictly better than today, where the triage happens in my head and leaves no trace.
                              • It scales across repos without scaling my attention. Enablement is a committed file per repo; there is no dashboard to maintain and no per-repo setup in the app.
                              • It makes the fork's existing autonomous-dispatch primitive useful for something other than rate limits.AutoResumeReactor proved the fork can drive thread.turn.start from the server with no client attached. This is the second consumer of that pattern, and it forces the missing half — server-side thread + worktree creation — into a reusable fork-local module that [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (loop supervision) and any future orchestrator would also want.
                              • It is a safety design as much as a feature. The interesting output is not "an agent opened a PR", it is a written contract for what an unattended agent in T3 Code is allowed to do: never full-access, never the project checkout, never push, never merge, always capped, always with a timeline entry. That contract does not exist anywhere in the repo today and will be needed by every autonomous feature that follows.

                              Smallest useful scope

                              v1: one repo, plan-only, no PR, no UI, opt-in and off by default.

                              Ship apps/server/src/t3x/maintainer/ with:

                              1. config.tsresolveConfig(env) for the global switch and interval (pattern: autoResume/config.ts:63), plus loading .t3x/maintainer.json from project.workspaceRoot (pattern: resolveResumePrompt, autoResume/config.ts:96-113, which never fails and falls through to a default).
                              2. detect.ts — one gh api repos/OWNER/NAME/issues?state=open&sort=updated&since=… call per enabled project per tick, via GitHubCli.execute; drops items with a pull_request key; applies the label allow/deny lists.
                              3. state.ts — durable JSON (t3x-maintainer.json in config.stateDir), holding lastSeenUpdatedAt and handled issue numbers per repo, plus the hourly-creation history for the cap. Verbatim structure from autoResume/state.ts.
                              4. decide.ts — pure: given detected issues, current state, and caps, return the list to act on. Fully unit-testable with no Effect services, like autoResume/decide.ts (71 lines).
                              5. Reactor.ts — self-starting scoped fiber: poll → decide → for each accepted issue, thread.create (interactionMode "plan", runtimeMode "auto-accept-edits") → thread.turn.start with a prompt containing the issue title, body and URL → thread.activity.append recording why the thread exists.
                              6. Registration: merge into T3xLayerLive in apps/server/src/t3x/index.ts. No other file changes except docs/t3x/SEAMS.md (see below).

                              Explicitly deferred out of v1: worktree creation, the implement path, draft PRs, the two-turn triage flip, any UI, and multi-project. v1 runs plan-mode only, in the project checkout's thread but with no working-tree writes, against a single repo I enable by hand.

                              v1 is done when: a new issue on radroid/t3code produces, within one poll interval and with no client connected, a T3 thread titled #NN — <title> containing a proposed plan and an activity entry naming the issue — and running the server for 24 hours with no new issues consumes a measured, negligible slice of the GitHub REST budget.

                              v1.1 adds the worktree (§3 steps 2-3), the easy implement path, and a per-project loop. v1.2 addsopenDraftPr. UI is v2 at the earliest, because a per-thread overlay costs a ledger row — mounting <AutoResumeOverlay> cost apps/web/src/routes/_chat.$environmentId.$threadId.tsx at +10/-6, churn 5, risk 80 — and v1 needs none: the threads show up in the normal list and the timeline carries the reasoning.

                              Alternatives considered

                              Wait for upstream pingdotgg#3164 and build nothing.pingdotgg#3164 is 🚧 In Progress and includes a Linear "new issue" trigger, so a GitHub issue trigger is plausible. Rejected as a complete answer because pingdotgg#3164 is a trigger transport — it will not decide plan-vs-implement, will not define the never-push contract, and will not create per-issue worktrees. It also has no landing date, and this fork's experience is that upstream orchestration work (PR pingdotgg#3638, merged into the t3code/codex-turn-mapping stack behind still-open pingdotgg#2829) can sit off main for a long time. The mitigation is architectural rather than temporal: keep detection behind detect.ts so pingdotgg#3164 landing deletes one file instead of invalidating the feature.

                              A GitHub Actions workflow instead of an in-app reactor. The repo already has Actions (.github/workflows/t3x-ci.yml). A workflow on issues: [opened] could call an agent CLI directly — real webhooks, no polling, no rate-limit question. Rejected because the output would be a CI log, not a T3 thread: no worktree the user can open, no timeline, no approvals, no checkpoints, no diff view, no mobile. The entire value here is that the artifact lands inside T3 Code where the human already reviews work.

                              Webhook receiver in the T3 server. Cleanest detection semantically. Rejected for v1: needs public ingress and a GitHub App or webhook secret, which is real setup on a laptop-hosted server and breaks the zero-setup bar the fork's other features meet. Worth revisiting if pingdotgg#3164 ships ingress the feature can borrow.

                              gh issue list --json … instead of gh api. Simpler and already the house style in GitHubCli.ts. Rejected because gh issue list --json goes through GraphQL, which is precisely the budget pingdotgg#3581 documents being exhausted by background polling. If the §8 experiment shows REST and GraphQL costs are comparable at one call per project per five minutes, this becomes the simpler choice and should be taken.

                              Model-side scheduling — let the agent arm its own wake-ups. The Claude platform binary ships CronCreate / ScheduleWakeup tools and a scheduler that runs in the SDK (non-interactive) entrypoint, and T3's ClaudeAdapter passes no allowedTools / disallowedTools / hooks, so they are reachable today. Rejected as the mechanism here for three reasons: it is Claude-only, durable crons are gated off so a loop dies with the query() session, and — decisively — it produces a turn but cannot produce a thread, which is the thing this feature actually needs.

                              A new MCP toolkit (apps/server/src/mcp/toolkits/maintainer/) so the model drives the loop itself. The t3-code MCP server is already mounted by all five adapters with a thread-scoped credential, and toolkits/preview/ is a complete working template. Genuinely attractive and provider-agnostic. Rejected for v1 because it inverts the control flow: the loop would then depend on some thread being alive and choosing to poll, which reintroduces exactly the liveness problem #38 exists to solve. A server-side reactor runs whether or not any agent is thinking. The MCP route is the better v2 surface for a human-in-the-loop "work issue #123 now" command.

                              Reuse AutoResumeReactor by hand — create the thread manually, let auto-resume keep it alive. Zero new code, but it does not detect anything and does not triage; it only solves "keep going after a limit".

                              Risks or tradeoffs

                              Seam cost: zero new ledger rows for the v1 above, one mandatory logic-mirror row.

                              docs/t3x/SEAMS.md currently measures 34 upstream-owned files, +1616 / -187 against merge-base 64bf01619, with an explicit tripwire at SEAMS.md:21: "Before adding row 35, re-isolate something instead."

                              • apps/server/src/server.ts already has a row (+3, churn 29, risk 87) covering the one import / one Layer.provideMerge / one route entry. Registering through t3x/index.ts adds nothing to it — that is the aggregator's entire purpose (t3x/index.ts module comment: "To add a feature: build it under apps/server/src/t3x/<feature>/, then merge its self-starting layer into T3xLayerLive below. Do NOT add a new edit to any upstream-owned file.").
                              • New logic-mirror row required: the worktree bootstrap in §3 duplicates ~25 lines of apps/server/src/ws.ts:908-930 (fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update). Mirrors produce no rebase conflict, which is exactly why they are dangerous — SEAMS.md:99 says so directly: "These never conflict during rebase, so nothing warns you when the original changes and the mirror drifts." If upstream adds a precondition to worktree creation (a lock, a path-collision check, a repo-state guard), the fork's path silently keeps working without it. Register it and re-check it every sync.
                              • Also a parallel-paths hazard, not just a mirror. This feature adds a second way to create a thread alongside upstream's dispatchBootstrapTurnStart. SEAMS.md:111 calls this out as "Worse than a mirror". It belongs in the parallel-paths table, with the upstream guard named explicitly.
                              • Any UI costs a real row. For comparison, apps/web/src/routes/_chat.$environmentId.$threadId.tsx is +10/-6, churn 5, risk 80 for mounting one overlay. Hence "no UI in v1".
                              • Do not touch packages/contracts/src/settings.ts (+7/-2, churn 18, risk 162, and a persisted schema). Hence the repo-committed .t3x/maintainer.json.

                              Behavioural and product risks

                              Examples or references

                              Upstream issues and PRs (pingdotgg/t3code)

                              Fork issues (radroid/t3code)

                              Code, with line references (paths relative to repo root)

                              Autonomous dispatch, the pattern to copy:

                              • apps/server/src/t3x/autoResume/Reactor.ts:96-119dispatchResume; engine.dispatch({ type: "thread.turn.start", … }), "byte-for-byte the path a keystroke produces"
                              • apps/server/src/t3x/autoResume/Reactor.ts:64-95appendActivity, best-effort thread.activity.append with catchCause
                              • apps/server/src/t3x/autoResume/state.ts:1-60 — durable JSON store, SynchronizedRef + atomic write, and the comment explaining why not a DB migration
                              • apps/server/src/t3x/autoResume/config.ts:63-73resolveConfig(env) with safe defaults; :88RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; :96-113resolveResumePrompt, a never-failing repo-file read
                              • apps/server/src/t3x/autoResume/decide.ts (71 lines) — pure decision function, the testability model for decide.ts
                              • apps/server/src/t3x/autoResume/Reactor.test.ts (345 lines) — the reactor test harness to model new tests on

                              Registration and layer wiring:

                              • apps/server/src/t3x/index.tsT3xLayerLive / T3xRoutesLive aggregator, with the "do NOT add a new edit to any upstream-owned file" rule in its module comment
                              • apps/server/src/server.ts:224Layer.provideMerge(T3xLayerLive) inside ReactorLayerLive
                              • apps/server/src/server.ts:346-352RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(… provideMerge(VcsLayerLive) …); :287GitWorkflowLayerLive inside VcsLayerLiveGitWorkflowService is reachable from t3x
                              • apps/server/src/server.ts:248-251GitHubCli.layer is Layer.provided into SourceControlProviderRegistry, not merged → t3x must provide it itself
                              • apps/server/src/server.ts:632Layer.provideMerge(VcsProcess.layer) at the outermost runtime → GitHubCli.layer's only dependency is satisfied

                              GitHub access:

                              • apps/server/src/sourceControl/GitHubCli.ts:199-247 — service shape; :203execute({ cwd, args, timeoutMs }) general escape hatch; :232createPullRequest; :239getDefaultBranch
                              • apps/server/src/sourceControl/GitHubCli.ts:28-137 — typed gh failures (GitHubCliUnavailableError, GitHubCliAuthenticationError, …) to surface as timeline activity rather than crash the fiber
                              • gh 2.96.0 supports gh api --cache <duration> (verified locally) — relevant to the §8 rate-limit experiment

                              Thread + worktree creation:

                              • apps/server/src/ws.ts:749dispatchBootstrapTurnStart, where bootstrap actually lives (not the engine); :891-906thread.create dispatch; :908-930fetchRemoteresolveRemoteTrackingCommitcreateWorktreethread.meta.update; :933runSetupProgram() then final dispatch
                              • apps/server/src/git/GitWorkflowService.ts:65-79createWorktree / fetchRemote / resolveRemoteTrackingCommit signatures
                              • packages/contracts/src/orchestration.ts:554-568ThreadCreateCommand; :671ThreadTurnStartBootstrap; :118-128RuntimeMode / ProviderInteractionMode ("default" | "plan") and their defaults; :244-254OrchestrationProposedPlan with implementationThreadId

                              Projects and queries:

                              • apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts:83getShellSnapshot() (use this); :75getSnapshot() (avoid — OOM risk recorded in SEAMS.md); :168getThreadDetailById
                              • packages/contracts/src/orchestration.ts:398-408OrchestrationProjectShell (workspaceRoot, repositoryIdentity, defaultModelSelection, scripts)
                              • packages/contracts/src/environment.ts:87-95RepositoryIdentity with optional provider / owner / name

                              Safety and limits:

                              • apps/server/src/provider/Layers/ClaudeAdapter.ts:3512-3517 — runtimeMode → Claude permissionMode (full-accessbypassPermissions); :3372-3378canUseTool auto-allows everything in full-access
                              • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:1323 — single DrainableWorker, session startup is serialized
                              • apps/server/src/provider/Layers/ProviderSessionReaper.ts:17 — 30-minute idle session reap
                              • apps/server/src/t3x/webPush/attention.ts:40attentionKindForEdge, the path that will fire a push for every maintainer thread completion

                              Seam discipline:

                              • docs/t3x/SEAMS.md:5 — 34 files, +1616/-187 against 64bf01619; :21 the row-35 tripwire; :71apps/server/src/server.ts row (+3, churn 29, risk 87); :61 the overlay-mount row (+10/-6, churn 5, risk 80); :96-109 logic-mirrors table; :111-119 parallel-paths table
                              • apps/server/src/t3x/autoResume/http.ts:1-12 and :35-45 — the raw-route pattern and an existing registered logic mirror, if a /api/t3x/maintainer route is ever added
                              • apps/web/src/t3x/AutoResumeOverlay.tsx + apps/web/src/routes/_chat.$environmentId.$threadId.tsx:18,92 — what a fork-local UI costs

                              Duplicate search performed before filing

                              Searched exhaustively across both repos before filing.

                              Upstream (pingdotgg/t3code), all 1,615 issues open+closed — the full title corpus was dumped locally (gh issue list --state all --limit 6000, count cross-checked against gh api search/issues … total_count = 1,615) and grepped for ~80 term variants, plus body-level gh search issues per concept, plus a gh search prs sweep. Terms included: auto triage, triage, respond to issues automatically, agent that watches, watch repo, issue bot, open a thread for each issue, implementation plan for each issue, draft PR, propose a fix, automatically open a PR, approve before, human approval, maintainer, automation, cron, schedule, trigger, webhook. Upstream Discussions are disabled (hasDiscussionsEnabled: false), so issues are the complete search surface.

                              Result: no duplicate. Nothing in either repo proposes an agent that watches a repo's issue queue, classifies each issue, and routes it to a plan or a reviewable branch. The closest issue-to-thread features — pingdotgg#3703 (manual Linear import) and pingdotgg#417 (worktree from a starting point) — are both human-initiated.

                              Real overlaps, disclosed and cross-referenced in the References section:

                              Fork (radroid/t3code), all 21 issues open+closed: no match. #38 (supervise long-running threads) and #39 (auto-resume cancellation bug) are adjacent and cross-referenced under Risks; neither proposes issue-queue work. Fork searches for maintainer, triage, coverage, architecture, parallel returned only unrelated t3x-sync and steering issues.

                              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