Skip to content

feat(server): threads persist linked GitHub issues - #1440

Merged
donjor merged 1 commit into
hyprwsfrom
t3code/thread-issue-links
Sep 30, 2026
Merged

donjor merged 1 commit into
hyprwsfrom
t3code/thread-issue-links

Conversation

@donjor-agent

@donjor-agent donjor-agent Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🤖 claude/anthropic claude-opus-5-5 (high)

What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

Model

  • Link: host, repository, number, url
  • Record: source (manual, handoff, agent), linkedAt
  • Snapshot: title, state, syncedAt
  • Commands: thread.issue.link, thread.issue.unlink
  • Sync: internal thread.issue-link.sync
  • Capability: threadIssues, missing means unsupported
  • Reverse lookup: RPC for the threads on one issue

Storage and reads

  • Table: idempotent ForkSchema.ts pass, never a numbered migration
  • Thread and shell: optional issues, omitted when empty
  • Live: shell subscribers refetch on a link change
  • Detail stream: carries no issue events
  • Sync order: an older snapshot never wins
  • Deletion: a re-created thread has no links

Upstream files change only by one-line fork-hook lines; logic lives in *.fork.ts.

Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

Version skew

Client Server Result
Old New decodes thread, shell and every stream item
New Old missing issues reads as none; capability off

A server from before this change cannot reopen a database holding link events; fork-delta.md records that downgrade limit.

Checks

tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0

The one failure is ServerEnvironment.fork.test on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

  1. Link issues 1430 and 1431 on a real thread
  2. Live shell update carries both links
  3. Reverse lookup returns the thread
  4. Duplicate link: rejected, no event, no update
  5. Unlink 1430: live update drops it
  6. Read-only database: projection row and events
  7. Unlink 1431: issues key gone

evidence-1440.gif

Stills

evidence-1440-frame.png
evidence-1440-final.png

Close conditions

Refs #1431

  • Replay: events rebuild the same projection
  • Stale sync: older than linkedAt is ignored
  • Duplicate: linking twice is a no-op
  • Re-link: works after an unlink
  • Authorization: only readable threads return
  • Deletion: deleted thread leaves the lookup
  • Gate: no link control without capability
Condition Evidence
Replay, re-link, deletion "link, sync, unlink, re-link and delete agree across every projection"
Stale sync "both projectors drop a sync for a removed link, an earlier link, or an older read"
Duplicate decider rejects it, no event; state unchanged
Authorization orchestration read scope, beside the pull request lookup
Gate server advertises threadIssues; the control lands with #1432, so this PR refs, not closes

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no

@rsi-software-agent rsi-software-agent Bot added the size:XXL 1,000+ effective changed lines (test files excluded in mixed PRs). label Sep 30, 2026
Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
@donjor
donjor force-pushed the t3code/thread-issue-links branch from ec45ad5 to ca3b407 Compare September 30, 2026 00:20
@donjor
donjor merged commit 041c524 into hyprws Sep 30, 2026
9 checks passed
@donjor
donjor deleted the t3code/thread-issue-links branch September 30, 2026 00:27
donjor added a commit that referenced this pull request Sep 30, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Sep 30, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Sep 30, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Sep 30, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Sep 30, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Sep 30, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Sep 30, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 1, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 1, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 1, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 1, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 1, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 1, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 2, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 2, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 2, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 2, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 2, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 2, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 2, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 2, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- 9151ae2 feat(server): threads persist linked GitHub issues (#1440) (#1440)
- 2822287 fixup! feat(server): threads persist linked GitHub issues (#1440) (#1584) (#1584)

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 4, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- 9151ae2 feat(server): threads persist linked GitHub issues (#1440) (#1440)
- 2822287 fixup! feat(server): threads persist linked GitHub issues (#1440) (#1584) (#1584)

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 4, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 4, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 4, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 4, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 4, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 4, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 4, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 4, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 6, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 6, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor added a commit that referenced this pull request Oct 6, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 6, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor pushed a commit that referenced this pull request Oct 6, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06df04 feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- ead034c fix(fork): adapt fork seams to v0.0.46-nightly.20261005.2689

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 6, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 8, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor pushed a commit that referenced this pull request Oct 8, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06df04 feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- ead034c fix(fork): adapt fork seams to v0.0.46-nightly.20261005.2689

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 8, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 10, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor pushed a commit that referenced this pull request Oct 10, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06df04 feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- ead034c fix(fork): adapt fork seams to v0.0.46-nightly.20261005.2689

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor added a commit that referenced this pull request Oct 10, 2026
…, and palette (#1442)

## What Changed

The web client links GitHub issues to threads from both sides, and **Work on this issue** links the issue at the draft's first send.

| Entry point | Does |
| --- | --- |
| Thread issues panel | "Linked to this thread" list, **Link**, unlink |
| Issue detail | "Linked threads" list, open, unlink, **Link to a thread** |
| Issue actions menu | link or unlink the active thread |
| Command palette | "Link GitHub issue to thread" |
| Work on this issue | links as `handoff` after the turn starts |

| Rule | Behavior |
| --- | --- |
| Handoff store | keyed by draft, in `localStorage` |
| Taken | only after a turn start succeeds |
| Multi-model send | links every started thread |
| Failed send | entry kept for the retry |
| Ask, Explain | mention the issue, never record it |
| Gate | every control needs `threadIssues` |
| Keybinding | none by default |
| Title, state | unknown until #1434 |

Upstream files change only by `fork-hook` lines in `ChatView.tsx` and `CommandPalette.tsx`.

## Why

**Work on this issue** only seeded a draft, so no thread existed to link until the first send.
This is layer 2 of #1430, on the link model from #1440.

## Checks

```text
tsc --noEmit (apps/web)                                → exit 0
tsc --noEmit (packages/client-runtime)                 → exit 0
vp test run githubIssue + CommandPalette               → exit 0 (13 files, 95 tests)
vp run knip:check                                      → exit 0
vp run fork:ci                                         → exit 0
```

## Evidence

This branch's dev server on a copy of real data, in Chrome.

### Thread side

1. Empty panel

![01-empty.png](https://github.com/user-attachments/assets/9e7178ae-c657-4871-b5af-ed80a12ce810)

2. **Link** dialog resolves a bare number

![02-dialog.png](https://github.com/user-attachments/assets/bea0ca98-4e9c-462b-9439-853c34b84986)

3. Linked; state unknown until a read

![03-linked.png](https://github.com/user-attachments/assets/de05b59d-713c-4775-bf03-790a49896120)

### Issue side

4. The same link under "Linked threads"

![04-issue-side.png](https://github.com/user-attachments/assets/39af8559-f194-4ae6-bba2-e6e8d9cd6a2d)

5. After unlink from the issue

![05-unlinked.png](https://github.com/user-attachments/assets/fdee9e99-54ab-4367-9c64-cebc927cb11e)

### Palette

![06-palette.png](https://github.com/user-attachments/assets/5ec42845-8e2f-40c6-95f0-d649e25fe442)

### Handoff

**Work on this issue** on this issue, then one send; the new thread links it.

![07-handoff.png](https://github.com/user-attachments/assets/2d245a65-707d-4b69-bb4e-bfa9a20ab73e)

Stored row: `source: handoff`, number 1432; the handoff entry was consumed.

## Close conditions

Closes #1432
Closes #1431

### Handoff

- [x] **First send:** links the issue as `handoff`
- [x] **Draft reuse:** the link survives
- [x] **Promotion:** the link survives
- [x] **Background send:** links the issue
- [x] **Failed send:** a retry links it once

### Surfaces

- [x] **Issue panel:** lists and opens linked threads
- [x] **Entry points:** issue panel, thread, palette
- [x] **Ask, Explain:** create no link

### Access (#1431)

- [x] **Gate:** no link control without capability

| Condition | Evidence |
| --- | --- |
| First send | capture 7 and the stored row; "links the draft's issue as a handoff at the first send, once" |
| Draft reuse | "survives a reload and a reused draft, where the latest issue wins" |
| Promotion | the entry is keyed by draft and taken after the promoted thread's turn starts |
| Background send | the link runs on the shared path after `startPromise`, foreground or background |
| Failed send | "keeps the issue through a failed send so the retry on a re-minted thread links it" |
| Issue panel | captures 4 and 5 |
| Entry points | captures 2, 4, and 6 |
| Ask, Explain | only **Work on this issue** records a handoff |
| Gate | panel, dialog, menu, palette, and handoff each check `threadIssues` |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

Capture 1 is the before state; no motion changed.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Co-authored-by: donjor <[email protected]>
donjor pushed a commit that referenced this pull request Oct 10, 2026
## What Changed

A thread stores its linked GitHub issues as orchestration state, modeled on its pull request links.

### Model

- **Link:** host, repository, number, url
- **Record:** source (`manual`, `handoff`, `agent`), `linkedAt`
- **Snapshot:** title, state, `syncedAt`
- **Commands:** `thread.issue.link`, `thread.issue.unlink`
- **Sync:** internal `thread.issue-link.sync`
- **Capability:** `threadIssues`, missing means unsupported
- **Reverse lookup:** RPC for the threads on one issue

### Storage and reads

- **Table:** idempotent `ForkSchema.ts` pass, never a numbered migration
- **Thread and shell:** optional `issues`, omitted when empty
- **Live:** shell subscribers refetch on a link change
- **Detail stream:** carries no issue events
- **Sync order:** an older snapshot never wins
- **Deletion:** a re-created thread has no links

Upstream files change only by one-line `fork-hook` lines; logic lives in `*.fork.ts`.

## Why

Threads link pull requests but not the issues they work on.
This is layer 1 of #1430: the web controls, MCP tools, sync and mobile build on it.

### Version skew

| Client | Server | Result |
| --- | --- | --- |
| Old | New | decodes thread, shell and every stream item |
| New | Old | missing `issues` reads as none; capability off |

A server from before this change cannot reopen a database holding link events; `fork-delta.md` records that downgrade limit.

## Checks

```text
tsc --noEmit (contracts, server, web, client-runtime, mobile) → exit 0
vp test run src/server.test.ts                               → exit 0 (211 tests)
vp test run threadIssues + orchestration (contracts)         → exit 0 (68 tests)
vp test run 10 server files                                  → exit 1 (146/147)
vp run fork:ci                                               → exit 0
```

The one failure is `ServerEnvironment.fork.test` on this machine: the local service launcher started a different t3 version.
CI is the authority for it.

## Evidence

A second client holds a live shell subscription on a dev server running a snapshot copy of real data.
The driver speaks the WebSocket RPC the clients use.

1. Link issues 1430 and 1431 on a real thread
2. Live shell update carries both links
3. Reverse lookup returns the thread
4. Duplicate link: rejected, no event, no update
5. Unlink 1430: live update drops it
6. Read-only database: projection row and events
7. Unlink 1431: `issues` key gone

![evidence-1440.gif](https://github.com/user-attachments/assets/770e9f45-7ff0-4d78-b58c-a9aecb88e6fc)

<details>
<summary>Stills</summary>

![evidence-1440-frame.png](https://github.com/user-attachments/assets/5e70a508-cf19-4a10-b877-804051efe5c4)
![evidence-1440-final.png](https://github.com/user-attachments/assets/fed5280e-60c7-4152-87e4-a9ca8fab5374)

</details>

## Close conditions

Refs #1431

- [x] Replay: events rebuild the same projection
- [x] Stale sync: older than linkedAt is ignored
- [x] Duplicate: linking twice is a no-op
- [x] Re-link: works after an unlink
- [x] Authorization: only readable threads return
- [x] Deletion: deleted thread leaves the lookup
- [ ] Gate: no link control without capability

| Condition | Evidence |
| --- | --- |
| Replay, re-link, deletion | "link, sync, unlink, re-link and delete agree across every projection" |
| Stale sync | "both projectors drop a sync for a removed link, an earlier link, or an older read" |
| Duplicate | decider rejects it, no event; state unchanged |
| Authorization | orchestration read scope, beside the pull request lookup |
| Gate | server advertises `threadIssues`; the control lands with #1432, so this PR refs, not closes |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the recording above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- b06fe75 feat(server): threads persist linked GitHub issues (#1440) (#1440) (#1584)
- b4d8082 feat(issues): threads keep linked issues on Orchestrator V2 thread state
- 0530808 feat(issues): the legacy importer carries V1 issue links onto V2 threads

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
donjor pushed a commit that referenced this pull request Oct 10, 2026
## What Changed

Agents link GitHub issues to their own thread over MCP, the way they link pull requests.

| Tool | Does |
| --- | --- |
| `link_issue` | links by URL, or repository and number, as `agent` |
| `unlink_issue` | removes a link; reports whether one existed |
| `list_thread_issues` | the thread's links with last known state |

- **Thread:** always the calling token's own thread
- **Host default:** the project's host when only a number is given
- **Repeat link:** `alreadyLinked`, no error
- **Absent unlink:** `wasLinked: false`, no error
- **Other rejections:** stay failures
- **Deleted thread:** not found, never a no-op
- **Stored URL:** canonical issue URL only
- **Hosts:** github.com or the project's host
- **Capability:** rides the pull request tools' gate
- **Instruction:** one line beside the pull request linking line

Upstream files change only by `fork-hook` lines in `McpHttpServer.ts` and `RuntimeInstructions.ts`.

## Why

Agents already register the pull requests they open; the issues they work on stayed invisible to the thread.
This is layer 3 of #1430, on the link model from #1440.

## Checks

```text
vp fmt --check (changed files)                         → exit 0
vp lint (changed files)                                → exit 0
tsc --noEmit (apps/server)                             → exit 0
vp test run issues toolkit + instructions              → exit 0 (13 tests)
vp run fork:ci                                         → exit 0
```

## Evidence

A real Claude thread on this branch's dev server, told to use only its MCP tools:

1. `link_issue` the issue URL
2. `link_issue` it again
3. `list_thread_issues`
4. `unlink_issue` it
5. `unlink_issue` it again
6. `list_thread_issues`

![tools-crop.png](https://github.com/user-attachments/assets/a0e64496-99a1-4e7d-acd9-b82819cc63dc)

## Close conditions

Closes #1433

- [x] link_issue: links by URL as agent
- [x] unlink_issue: removes the link
- [x] list_thread_issues: lists the thread's links
- [x] Idempotent: a repeat link changes nothing
- [x] Scope: a call reaches only its own thread
- [x] Missing thread: fails, never "already linked"

| Condition | Evidence |
| --- | --- |
| link_issue | "links by URL with source agent on the token's thread" |
| unlink_issue | "unlinks a linked issue and reports a missing one as wasLinked=false" |
| list_thread_issues | "lists the shell's links, with unknown state until the first read" |
| Idempotent | "treats a duplicate link as alreadyLinked rather than an error" |
| Scope | the thread comes from the invocation token, never the input |
| Missing thread | "fails cleanly when the token's thread no longer exists"; "reports a thread deleted between lookup and dispatch as not found" |

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for any UI changes
- [x] I included a video for animation/interaction changes

No UI changes; the capture above is server evidence.

Opus 5.5 via Claude Code in T3 Code.

## Fork trailers

Squashes:

- 8022f70 feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- 05e302b fixup! feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- dda4b19 fixup! feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- b06df04 feat(server): agents link GitHub issues to their thread over MCP (#1441) (#1441)
- ead034c fix(fork): adapt fork seams to v0.0.46-nightly.20261005.2689

Fork-Domain: github-issues
Fork-Tier: qol
Fork-Upstreamable: no
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ effective changed lines (test files excluded in mixed PRs).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant