Skip to content

Conversations: pin, archive, full-text search and export (#18) - #99

Merged
derekhearst merged 27 commits into
mainfrom
feat/18-conversation-lifecycle
Sep 24, 2026
Merged

derekhearst merged 27 commits into
mainfrom
feat/18-conversation-lifecycle

Conversation

@derekhearst

Copy link
Copy Markdown
Owner

Stacked on #98 (feat/32-orchestration); retargets to main once that merges. Adds migration 0079_conversation_lifecycle (pin/archive columns + a full-text search index) — not booted against the shared dev database; CI runs the specs on a fresh one. On first boot the search index is built for existing messages (SEARCH_BUILDER_VERSION 2).

Summary

Implements all four parts of #18. Schema: conversations gains nullable pinned_at/archived_at timestamps, and a new message_search side table holds each message's searchable text plus a generated tsvector with a GIN index. The migration is drizzle/0079_conversation_lifecycle.sql, made by db:generate; db:check passes and a second db:generate reports no changes. Pin and archive live in conversation-lifecycle.server.ts. Pinning unarchives, archiving unpins, and neither moves updatedAt. A message the user sends unarchives the chat; automation and monitor writes do not. getConversations now leaves archived chats out, returns every pinned chat plus the 50 most recent others, and trims the lastMessage preview to 280 characters. getArchivedConversations lists the archive, and the list fingerprint now also moves on pin and archive. Search: buildMessageSearchText indexes message text, attachment names, each tool call's paths, commands, short arguments and printed URLs, and subagent tasks. It skips file bodies, edit payloads, raw output, thinking and system messages, caps at 60k characters, and spells each path out as segments so options.server.ts, engine/options.server.ts and options all match. searchUserConversations ranks with ts_rank_cd, highlights with ts_headline using control-character markers, merges title matches, scopes to the caller, and hides archived chats unless asked. Indexing runs fire-and-forget after commit, and a batched keyset backfill runs at boot. Export: GET /chat/[id]/export?format=md|json downloads a readable Markdown transcript or a complete JSON file; another user's chat returns 404, and responses are attachment, no-store and nosniff. UI: each sidebar row has a menu (Archive first plus a one-click hover button, Pin, Rename, two exports, Delete last behind the danger confirmDialog). Pinned chats get their own group, the Status filter has a real Archived view, and a 250ms-debounced server search shows snippets rendered as text. On touch or phone widths the menu button is always visible. Docs, README and the parity rows are updated.

After review: Addressed every must_fix, should_fix and claimed-but-not-fixed item from the review of issue #18, plus minor notes 1, 2, 3 and 5. The branch now has 21 commits and a clean worktree.

  1. Search snippets. A message of up to 100 characters is now shown whole with its matches marked (ts_headline HighlightAll). Longer messages keep two excerpts, now with ShortWord=0 so short words at the edges are kept. I checked this read-only against Postgres 17 using literal strings only: "fix the [login] bug now" and "fix R&D <3 [word] now" come back whole.
  2. Search backfill. A batch that fails on a foreign key (a message deleted mid-batch) is retried one row at a time, and the deleted rows are skipped. The backfill also takes an optional conversation scope, which the spec now uses so it does not index other workers' rows.
  3. Export file names. The readable file name is now cut by code points, and lone surrogates are dropped, so an emoji at the 80-character cut no longer causes a 500.
  4. Sidebar order. The uncommitted ordering work is now committed as src/lib/chat/conversation-order.ts with a unit spec. Pinned chats are ordered by when they were pinned. The Archived view is ordered, grouped and dated by when each chat was archived.
  5. Delete. The uncommitted delete work is now committed as src/lib/chat/conversation-delete.server.ts with 4 specs. It checks ownership first, stops any running turn through the run registry, waits up to 10 seconds, then deletes.
  6. Home page on a phone. "Recent chats" now shows the five most recently active chats, so pinned chats no longer push newer ones off the list.
  7. Minor notes. Pin and archive now answer 404 instead of throwing a plain Error for someone else's chat. The Archived view shows "Loading…" until its list arrives. The row menu refetches the archive only when the change affects it. The desktop lifecycle spec now bumps the chat's activity before Unarchive so it stays in the 50-most-recent list.

Docs (chat.md, chat-console.md) are updated. bun run check (0 errors), bun run build and db:check all pass.

Closes #18

What was addressed

Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.

# Item Verdict Note
must_fix: message-search-sql.ts:81 short-message snippet must_fix: message-search-sql.ts:81 short-message snippet fixed snippetSql(): a body of 100 characters or fewer uses ts_headline HighlightAll=true (the whole message, matches marked). Longer bodies use MaxFragments=2 with ShortWord=0. The root cause was ShortWord trimming the three-letter words at the document edge. Checked read-only on literal strings: short, short-word, '<3' and long cases all keep their context. New data-layer spec: 'the snippet shows a short message whole, and a long one around the match'. The sidebar spec's R&D <3 ${word} now expectation now matches.
should_fix: ConsoleNavContent.svelte:148 client re-sort discards server order should_fix: ConsoleNavContent.svelte:148 client re-sort discards server order fixed Committed src/lib/chat/conversation-order.ts (groupConversations/listTime/sortConversations). Pinned are sorted by pinnedAt, the archive by archivedAt (sort, day grouping and row time label), the rest by updatedAt. Added tests/chat.conversation-order.spec.ts (9 pure tests; they pass under a bun shim that runs only pure code).
should_fix: chat.remote.ts:143 uncommitted delete/order work should_fix: chat.remote.ts:143 uncommitted delete/order work fixed Both modules are committed with specs. tests/chat.conversation-delete.spec.ts covers four cases: a live run is interrupted and the delete waits for it to end; a stranger's delete neither deletes nor interrupts (ownership is checked first); a turn that is slow to stop is bounded by the settle time; with no live turn it is an ordinary delete and a finished run is left alone. chat.md documents it.
should_fix: conversation-export.ts:382 surrogate split -> URIError 500 should_fix: conversation-export.ts:382 surrogate split -> URIError 500 fixed The name is sliced by code points and lone surrogates are stripped first. The spec covers the 79 'x' + emoji title and a title with a lone surrogate; neither throws in exportContentDisposition.
should_fix: message-search.server.ts:109 backfill FK 23503 aborts boot backfill should_fix: message-search.server.ts:109 backfill FK 23503 aborts boot backfill fixed New indexMessagesSkippingGone: on 23503 it retries row by row and skips rows that are gone. backfillMessageSearch accepts an optional conversationId, and the spec is scoped with it. New spec: a batch with one deleted id writes the other row and does not throw.
should_fix: routes/+page.svelte:295 phone Recent chats show only pinned should_fix: routes/+page.svelte:295 phone Recent chats show only pinned fixed The list is sorted by last activity (sortConversations 'Recency') before slice(0,5). New mobile spec seeds 5 old pinned and 5 fresh chats and asserts that none of the pinned ones appear under Recent chats.
claimed-not-fixed: pinned group orders by pin time claimed-not-fixed: pinned group orders by pin time fixed Now true on HEAD via conversation-order.ts, and pinned by the order spec.
claimed-not-fixed: archive 'most recently archived first' claimed-not-fixed: archive 'most recently archived first' fixed Now true on HEAD: sorted, grouped and labelled by archivedAt. Docs expanded to match.
claimed-not-fixed: search returns a matching snippet claimed-not-fixed: search returns a matching snippet fixed See the must_fix entry. Short messages now return full context.
minor 1: plain Error -> 500 on pin/archive of a foreign chat minor 1: plain Error -> 500 on pin/archive of a foreign chat fixed The server functions return null, and the remote commands call error(404). The spec is updated.
minor 2: refreshLists always refetches the archive minor 2: refreshLists always refetches the archive fixed The archive is refetched only when the row is archived or the action is archive/unarchive.
minor 3: 'Nothing archived.' while loading minor 3: 'Nothing archived.' while loading fixed Shows 'Loading…' until archivedQuery.ready (or an error).
minor 4: regenerate/edit in an archived chat does not unarchive minor 4: regenerate/edit in an archived chat does not unarchive deferred Kept as documented: only a new user message brings a chat back.
minor 5: desktop UI spec could lose the unarchived chat under 8 workers minor 5: desktop UI spec could lose the unarchived chat under 8 workers fixed The spec bumps updated_at before Unarchive.
minor 6: backfill vs live edit race on first boot minor 6: backfill vs live edit race on first boot deferred The window is rare and short, and the next edit or reindex corrects it. Not addressed.
minor 7: prefix stemming quirk while typing minor 7: prefix stemming quirk while typing deferred A Postgres FTS behaviour. Not addressed.

Verification

  • bun run check: 0 errors
  • bun run build: passes
  • Specs: Playwright was not run and the app was not booted, as this branch requires (it adds migration 0079). Checks that were run: - bun run check: 0 errors, tests included. - bun run build: exit 0. - db:check: fine. - tests/chat.conversation-order.spec.ts: its 9 pure tests pass under bun with a shim for @playwright/test (no DB, no app). - Search SQL: read-only SELECTs of ts_headline over literal strings on the dev DB (no table access). The rendered snippetSql and textQuery run and produce the expected extracts. - Export file names: sanity-checked directly with bun. New and changed specs for CI: tests/chat.conversation-search.spec.ts, chat.conversation-export.spec.ts, chat.conversation-lifecycle.spec.ts, chat.conversation-order.spec.ts, chat.conversation-delete.spec.ts.
  • Adds a migration — not booted against the shared dev database; CI runs the specs on a fresh one.
  • Independent review: changes-needed (1 must-fix, 5 should-fix), addressed in a follow-up round.

Docs updated

  • docs/chat/chat.md
  • docs/chat-console/chat-console.md
  • README.md
  • docs/structure/claude-desktop-parity.md
  • docs/structure/parity-reaudit-2026-09-22.md

Follow-ups / residual risk

  1. README conflict. git merge-tree against origin/main (0bb97b6) still reports a content conflict in README.md; nothing else conflicts. It needs resolving when the PR is opened or main is merged in. I did not merge main here, per the no-merge rule.
  2. Migration number. 0079_conversation_lifecycle is still free on origin/main, whose latest migration is 0078.
  3. Playwright not run. The new and changed specs have not been executed with Playwright (branch rule), so CI is their first real run. Worth watching:
    • the delete spec: it relies on the spec and the server module sharing one run-registry instance, as tests/runs.lifecycle.spec.ts already does;
    • the mobile home "Recent chats" spec;
    • the sidebar search spec.
  4. Deferred minors. Minor 4 (regenerate or edit does not unarchive; documented as intended), minor 6 (first-boot backfill vs live-edit race) and minor 7 (FTS prefix stemming) are left as they are.
  5. Intermediate commits. Some intermediate commits were staged hunk by hunk. I checked that each intermediate file state is coherent. The home-page commit was amended once to fix a misplaced test block before anything else was built on it. The final tree is exactly what passed check and build.

🤖 Generated with Claude Code

derekhearst and others added 27 commits September 23, 2026 16:07
conversations gains pinned_at and archived_at. Timestamps rather than
booleans: set means on, and the time orders the pinned group and dates the
archive.

message_search is a side table with one row per message: the text worth
finding it by (body, built in code) and a tsvector generated from it with a
GIN index. A side table rather than columns on messages, because the chat
page selects every messages column and would ship the search text to the
browser, and because a builder_version column lets the index be rebuilt
when what is indexed changes. Both FKs cascade, so deleting a message or a
conversation removes its search rows.

Migration 0079, generated by db:generate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildMessageSearchText turns a message into the text it is found by: the
message itself, attachment names, and one line per tool call with its
name, file paths, commands, short arguments and any links in its output.
Raw tool output, file bodies, edit payloads, thinking and system messages
are left out, and the result is capped at 60k characters so an insert can
never hit the tsvector size limit.

Postgres reads src/lib/engine/options.server.ts as a single token, so a
search for "options" or "engine/options.server.ts" would miss the turn that
edited it. Every path is followed by its basename and its segments as
words, and searchQueryParts rewrites a path-shaped search as the matching
phrase of segments. The last plain word is matched as a prefix, so results
appear while typing.

searchUserConversations ranks the best message per conversation with
ts_rank_cd, highlights it with ts_headline using control-character
markers (the sidebar renders them as text, never HTML), merges in title
matches, and is scoped to the caller and to unarchived chats unless asked.

Indexing never fails or delays a message write: insertMessageWithSequence
hands a committed row to scheduleMessageIndex, and anything missed is
picked up by backfillMessageSearch, which runs in the background at boot.
It walks messages by id and trims tool output in Postgres before sending
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setConversationPinned and setConversationArchived set or clear the
timestamps, scoped to the caller. Pinning unarchives and archiving unpins,
and neither moves updatedAt, so tidying a chat never reorders it. A
message the user sends into an archived chat brings it back; automations
and monitors writing into one do not.

getConversations now leaves archived chats out and returns every pinned
chat on top of the 50 most recent, with a 280-character preview instead of
the whole last reply. getArchivedConversations lists the archive. The list
fingerprint the monitor pushes also counts pins and archives, since those
no longer move updatedAt.

searchConversations is the remote query behind the sidebar search. The
stream's final assistant message and an edited message are indexed after
they are committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /chat/[id]/export?format=md|json downloads one conversation. Markdown
is a readable transcript: a header with dates, agent, model and totals,
then each message with its blocks in order - edits as diffs, shell calls
as the command and its output, todo lists as checklists, subagents as
quotes, thinking folded into <details>. Long tool output is cut short with
a pointer to the JSON, and every code fence is longer than any run of
backticks inside it, so tool output cannot break out.

JSON has everything unshortened: the conversation (without the owner's
id) and every message with its full metadata and blocks.

Someone else's conversation answers 404, like one that does not exist.
The response is an attachment with an ASCII and a UTF-8 file name, not
cacheable and nosniff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… search (#18)

Each sidebar row gets a menu: Archive (first, and a one-click button on
the row on a pointer device), Pin to top, Rename, Export as Markdown or
JSON, and Delete last, behind the danger confirm dialog, which says what
goes with it and suggests archiving instead. Deleting the open chat goes
home. The menu opens inline under the row, so the scrolling list and the
phone drawer never clip it, and on a touch screen or a phone-width drawer
the button is always shown and the row makes room for it.

Pinned chats form their own group above the others in every grouping.
The Status filter's "Archived" used to mean "no running turn"; it is now
the archive, with a bar to get back, and "Active" is renamed "Running".

The search box keeps its instant filter over loaded titles and, 250ms
after typing stops, asks the server. Matches in messages and tool calls
show under "In messages" with the title, an archived badge, the date and
the snippet, whose highlights are rendered as <mark> elements from the
markers - never as HTML from the database. A pin, archive, rename or
delete re-runs the search, and the archive view refreshes when another
tab changes the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chat.message-search-text: what a message is indexed by (paths with their
file name and segments, commands, links in output, short arguments) and
what never is (file bodies, edit payloads, thinking, raw output, system
messages), the 60k cap on a 5MB message, how a typed search becomes a
text query, and that snippet highlights are split as text.

chat.conversation-search: a tool call's file found by its full path, file
name, partial path, segment and a prefix while typing, indexed by the
write itself; archived chats only when asked; nothing for another user;
stop-word searches still find titles; the backfill indexes what the
writer missed and rebuilds rows from older rules; the sidebar shows the
snippet with <mark> and the rest as text.

chat.conversation-lifecycle: pinning unarchives, archiving unpins,
updated_at never moves, the default list keeps every pin and leaves the
archive out, a user's message unarchives and an automation's does not.
In the sidebar: pin, rename, export, one-click archive, the Archived view
and Unarchive; delete asks and leaves the open chat; on a phone the menu
button shows without hover and the title keeps its width.

chat.conversation-export: the Markdown and JSON formatters, fences that
tool output cannot close, and the route's headers, 400, 404 and refusal
without a session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chat.md is the chat domain doc the folder never had, written for the
owner: pin, archive (and what brings a chat back), rename, delete and
what it removes versus keeps, how search works and what it does and does
not index, and the two export formats. It corrects the re-audit's claim
that delete takes the cost ledger with it: those rows stay and lose their
run link.

chat-console.md: how the sidebar shows it - the Pinned group, the row
menu on pointer and touch devices, the Status views (the old "Archived"
meant "not running"), and the search results.

Parity: rename was scored even with no UI calling it; it has one now.
Pin/archive is behind only for folders, search and export are even. The
re-audit marks #18 shipped. README: feature paragraph, docs link and the
export route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…added

Date groups were keyed by their label, and day labels drop the year, so a
chat from the same date a year earlier gave two groups one key - a
duplicate-key error in a keyed each. Groups are now keyed by the day or
the category. The search snippet is rendered on one line so the markup's
whitespace does not pad each highlighted word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a pointer device the row's Archive and menu buttons are invisible until
the row is hovered or focused, but they still sat over the time label and
took clicks. A tap there on a touch-screen laptop, whose primary pointer
reports hover, archived the chat without showing why. Hidden now also
means pointer-events: none; hovering the row turns them back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a search running on the server, an empty local list said "No chats
match." while the results section below said "Nothing found in messages."
The local message now shows only for searches too short to send, and the
results section speaks for the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Escape was handled only inside the open menu, so with focus still on the
"⋯" button it went to the phone drawer and closed that instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ts_headline's excerpt mode will not start or end an excerpt on a word of
three letters or fewer. When the excerpt reached either end of a short
message it trimmed those words away, down to the match itself: "fix the
login bug now" came back as just "login", and "Deploy it" as "Deploy".
Short messages are what people search for most.

A message of up to 100 characters is now shown whole, with the matches
marked. Longer ones keep two excerpts around the matches, with ShortWord=0
so their edges are kept too. The spec pins both, and the sidebar spec's
"R&D <3 word now" extract now holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A backfill batch was one multi-row upsert. A message deleted between the
batch's read and its write (its conversation deleted meanwhile) failed the
whole statement on its foreign key, and the boot backfill stopped there
until the next restart; on a first deploy that is the whole history.

The batch now falls back to one row at a time on a foreign-key violation
and skips the rows that are gone. The backfill also takes an optional
conversation, which the spec uses so it no longer indexes other workers'
rows while they are being inserted and deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The readable file name was cut at 80 UTF-16 units. An emoji across the
cut left half a surrogate pair, encodeURIComponent threw on it, and the
download answered 500. The name is now cut by code points, and a lone
half already in a title is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ime (#18)

The server lists pinned chats by when they were pinned and the archive by
when each chat was archived, but the sidebar re-sorted every list by last
activity and threw that order away. The Pinned group followed activity,
and the Archived view was ordered, grouped by day and dated by last use
rather than by when each chat was put away.

The sorting and grouping move to $lib/chat/conversation-order, a plain
module a spec can load without a browser: pins by pin time, the archive
by archive time (each row's time is when it was archived there), the rest
by last activity. Sort by Name or Project still applies in every group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the Archived view said "Nothing archived." while its list was
still on its way; it now says "Loading…" until the list arrives. And the
row menu refetched the archive after every change, archive open or not;
it now does so only when the chat is in the archive or is going into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page shows the first five chats of the list on a phone, where
the sidebar is hidden. The list now starts with every pinned chat, so
someone with five pins saw only those, however old, and never their
latest chat. The five are now picked by last activity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setConversationPinnedForUser and setConversationArchivedForUser threw a
plain Error for a conversation the caller does not own, which surfaced as
a 500 in the server log. They now return null, and the remote functions
answer 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delete cascades to the conversation's chat_runs rows, and Stop finds
a run through its row. Deleting under a live turn left the agent running
tools with nothing able to stop it, until its final write failed on a
conversation that no longer existed.

deleteConversationForUser checks ownership, interrupts the conversation's
unfinished runs through the run registry (as Stop does), waits up to ten
seconds for them to wind down, and then deletes. Another user's delete
touches nothing. A turn held by the jobs worker is out of reach here,
exactly as it is for Stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar spec expects a chat back in the list after Unarchive. The list
holds the 50 most recently active chats, and the chat was created about
30 seconds earlier while other workers seed chats for the same owner, so
it could fall out of the list. It now has fresh activity before Unarchive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cts (#18)

The chat doc now says the Pinned group goes by pin time and the archive
by archive time, that a delete stops a running turn first and waits up
to ten seconds, how long an extract is, how export file names are cut,
that a phone's "Recent chats" ignore pins, and that the backfill skips a
message deleted under it. The console doc covers the sidebar's side:
pin order, the archive's order and its loading state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the wave-3 chain below #18: #22 composer mentions, #24 rewind,
#4 native AskUserQuestion, #35 live shell and #32 orchestration (with
#14's rail and #9's gateway as far as #32 had them).

Resolutions:
- src/lib/chat/conversation-delete.server.ts (add/add, #18 vs #35): both
  stopped a conversation's live turn before deleting it. Kept #18's
  version (ownership checked before anything is stopped, every unfinished
  run interrupted through the run registry, a bounded wait for the turn to
  wind down) and folded in #35's reasoning: with no perTaskStopAffordance
  the interrupt also kills background commands and delegated children.
  One result shape, { deleted, stoppedRuns }.
- tests/chat.background-commands.spec.ts: #35's delete tests expect that
  shape ({ stoppedRuns: 1 | 0 } for { stoppedRun: true | false }); what
  they check is unchanged and still holds (no claim, so no wait).
- src/lib/chat/chat.remote.ts: #18's pin/archive/search/archived-list
  functions alongside #24's edit/regenerate through message-branch.server
  and previewRewind; one deleteConversationForUser import; gt/ne/or
  dropped with the inline edit code; scheduleMessageIndex moved out.
- src/lib/chat/message-branch.server.ts (merged clean, semantic): #18
  re-indexed an edited message for search in the old inline editMessage;
  #24 moved the edit here, so editUserMessage now returns the edited row
  from its transaction and schedules the index after the commit.
- src/lib/chat-console/Icon.svelte: #14's 'panel' plus #18's 'pin',
  'archive', 'download', 'dots', 'trash'.
- README.md: #18's conversation-lifecycle paragraph next to #35/#24/#4/
  #9/#22's; the Docs list names chat.md once for both halves; the route
  map keeps #14's /chat/[id] rail note, #18's export route and /runs/[id].
- docs/chat/chat.md (add/add, #18 vs #24): one domain doc covering the
  conversation list (pin, archive, rename, delete, search, export) and
  changing a conversation after the fact (edit, regenerate, restore
  files, compact); anchors other docs link to are kept.
- docs/structure/parity-reaudit-2026-09-22.md: one row each for #4
  (shipped), #18 (shipped) and #22 (split, both halves shipped); the
  "cheap independents" line strikes #4, #18 and #14; #18's section notes
  the shared delete path and the edit re-index.
- docs/structure/claude-desktop-parity.md: #18 marked landed in wave 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in what main gained after the chain below #18 was cut: #92 (#14
right rail), #93 (#9 gateway), #94 (#22 composer mentions), a chat on a
retired Claude model moving to the settings default
(resolveRunnableModel), and getOrCreateSettings creating a user's
settings row once under an advisory lock.

No textual conflicts. Checked for semantic ones: the stream route's model
fallback runs before resolveParentMessage, so #18's unarchive-on-send and
search indexing are untouched; no #18 spec reads app_settings or a
conversation's model; the migration journal is unchanged (main adds no
migration, #18 owns 0079_conversation_lifecycle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained #95 (feat/24-rewind) while the previous merge was being
checked. Everything #24 brings was already here through
feat/32-orchestration's chain (feat/24-rewind -> feat/4 -> feat/35 ->
feat/32), including b122672's hooks spec fix, so the tree does not
change; this commit only makes main an ancestor so the PR merges cleanly.

No conflicts, textual or semantic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tes it (#18, #32)

#18 found delegated work through the delegation's own Task/Agent tool
block (its prompt and subagent_type are indexed arguments) plus the
subagent block's task and text. #32 dropped that tool block, so the
card is now the delegation's only record, and it keeps the child's own
calls on `transcript`, each labelled with the path, command or pattern
it touched. Neither search nor the Markdown export read them, so a turn
whose edit was delegated could not be found by the file edited, and a
child the user stopped was exported with a failure cross.

- message-search-text: a subagent line also carries the child's
  distinct tool calls with their labels (paths spelled out as
  segments), and its final report when it said nothing on the way.
  SEARCH_BUILDER_VERSION 2, so the boot backfill rebuilds older rows.
- conversation-export: the card lists the child's calls, uses the
  report as the body when there is no text, reads `stopped` as stopped
  (not ✗) and keeps the reason. A pre-#32 card reads as before.
- specs for both; docs (chat.md, the parity pages) say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with #18

docs/chat/spec.md and #35's section of the re-audit said a delete stops
the turn and then deletes. Since the merge they run through #18's
deleteConversationForUser, which checks ownership first and waits up to
ten seconds for the stopped turn to save what it had done; both now say
so and point at chat.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main as of #97 via the orchestration branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derekhearst
derekhearst changed the base branch from feat/32-orchestration to main September 24, 2026 07:01
@derekhearst
derekhearst merged commit 77489d3 into main Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Conversation lifecycle: pin, archive, real search, export

1 participant