Imported from afollestad/alveary (
Alveary/Services/Threads/AGENTS.md). Install upstream withnpx skills add afollestad/alveary --skill Threads. Copyright stays with the author.
Thread Services
App-scoped thread lifecycle plus the alveary_host thread tools. Alveary/Services/HostMCP/AGENTS.md owns the generic handler contract (strict validation, trusted identity, source-first reads, dual name shapes, exposure) and how a feature enrolls; the rules below are what threads add on top.
Lifecycle
ThreadLifecycleServiceis the shared implementation of thread creation and archiving;Alveary/ViewModels/Sidebar/AGENTS.mdowns the split withSidebarViewModeland the external-archive selection rule.insertTaskThreadputs a directory on disk before it persists anything, so a failed save removes that private workspace again; otherwise the orphan survives until the next launch's sweep.TaskThreadSeed.grantedRootsmust already be canonical — the seed is rehydrated, never re-resolved, so a symlink swapped in since validation cannot change what was granted.- The rollback removes only a workspace this call minted.
TaskThreadSeed.workspacemay be a checkout another thread is still working in, so its owner cleans it up, not the insert.
- The rollback removes only a workspace this call minted.
replaceTaskWorkspaceis for a caller that had to answer before the real workspace existed — the pull request pane answers on the click and settles the checkout behind that return. It swaps the descriptor, saves, then awaits removal of a replaced private workspace off the main actor; it never touchesbranch,worktreePath, oruseWorktree, andAlveary/Services/PullRequests/AGENTS.mdowns why a Task's branch must stay nil.
Host Tools
- No tool deletes a thread, and none may be added. Archiving is reversible from the Archived screen and deletion is not; the instructions fragment tells the model never to describe archiving as deletion.
- A conversation cannot archive its own thread. Archiving kills the calling harness process mid-call, so the result would never arrive and the model could not tell whether it applied.
list_threadsmarks that rowis_currentso the refusal is predictable rather than a surprise error. - Thread IDs are sole-main-conversation IDs, the same handle
propose_scheduled_task.target_thread_idtakes. A forked thread has no sole main conversation and is therefore unlistable — including, legitimately, the caller's own. AgentThread.isListableHostToolThreadis defined asisEligibleScheduledTaskTargetplus a sole main conversation, which is the ID a listed thread travels as. Keep it that way rather than restating the clauses, so a listed thread can never be one scheduling would refuse.create_threadapplies immediately and keeps a receipt. The ledger (Conversation+ThreadHostToolReceipts.swift) is what stops a replayed call from making a second thread and re-dispatching its initial prompt.archive_threadneeds no ledger — it is idempotent, and already-archived is a success.- Persist the receipt in its own save. A failed ledger write must not roll back a thread the user can already see.
- Snapshot the caller's workspace and sidebar placement before harness discovery. An omitted project inherits those saved folders; an explicit
project_iduses current project defaults.primary_folder_pathselects a member, and legacyproject_pathmust identify exactly one project.- Keep placement separate from execution: empty projects accept private workspaces, and an explicit section retains an inherited source workspace. A vanished inherited project or section falls back to Tasks without dropping saved grants.
- Treat omitted
granted_rootsas inheritance and[]as removal of every additional grant, for either execution mode. Keep the full structured creation result in the receipt so retries report the original workspace after project edits.
- Inherit omitted harness, model, and effort through
ThreadHostToolSourceSettings. For OpenCode, reject an unavailable inherited harness or model instead of silently substituting; other harnesses retain their trusted-model inheritance. Permission mode still falls back to the user's defaults. - Grants are canonical absolute folders that already exist, resolved in the handler because existence is host state;
Alveary/Services/Scheduled/HostTools/AGENTS.mdowns why the stored form is canonical. Two spellings of one folder collapse there, which the parser's literal duplicate check cannot see. - Re-resolve models after the defaults resolver's
await. It suspends on harness discovery; the Project or the calling conversation can vanish across it. - Validate every setting against host state and name the valid values. Harness against
readyHarnessIDs, model throughAgentModelOptionSelection, effort against that model's supported options, permission mode againstAppSettings.supportedPermissionModes(forHarness:)— a rejection the model can act on beats one it has to guess at. - Every tool here serves an automated scheduled run — never add a caller-side gate back. Fanning work out through
create_threadis the point. What an unattended run may do to a given thread is refused at that thread instead:archive_thread's live-run and in-flight-review-submission guards, which mirror the sidebar's (Alveary/ViewModels/Sidebar/AGENTS.md). pin_thread/unpin_threadmove sidebar placement, nothing else. A thread under a pinned project is refused with the reason rather than reported as pinned — the pinned project already carries it, so the pin would render nowhere and a success would be a lie. Neither is schedule-guarded: a schedule does not own its target's placement.create_sectionandmove_thread_to_sectionmove sidebar placement, nothing else — the same contractpin_threadcarries, and the reason neither keeps a receipt: a section's existence and a thread's membership are each their own record, so a replay reportsalready_existsoralready_in_sectionrather than duplicating anything.- No tool removes or renames a section, and none may be added. Removal moves a section's threads to
Tasksand is a user action in the sidebar, for the same reason deletion is;testCatalogAdvertisesNoDeletionToolpins the whole name list against all three. sectionnames a section, never creates one.create_threadandmove_thread_to_sectionboth refuse an unknown name and list the real ones, so agents composecreate_sectionfirst; auto-creating on a typo would mint sections the user never asked for.Tasksresolves to no membership — it is how a thread leaves a custom section — whilePinnedandProjectsare refused by name, becausepin_threadand a Project placement are what put a thread in either.- Only plain values cross
create_thread's defaults-resolverawait— a section ID or project UUID, never a SwiftData row;insertTaskThreadre-resolves the seed's placement inside the creating save, so an explicitly named placement removed mid-call fails the insert instead of half-applying. move_thread_to_sectionunpins a pinned thread, because a pinned thread renders above every section and reporting a placement the sidebar would not show is the liepin_thread's absorbed-pin refusal exists to prevent.
- No tool removes or renames a section, and none may be added. Removal moves a section's threads to
link_pr,unlink_pr, andlist_linked_prschange or read Alveary's record only. Nothing here calls GitHub except the validatingfetchDetail; the instructions fragment must keep saying so, because "unlink" reads like "close the PR" to a model. All three defaultthread_idto the calling conversation's thread, which is the common case and is whatis_currentmakes predictable.unlink_prresolves an omittedurlagainst the thread — its only link,not_linkedwhen it has none, and a refusal naming the candidates when it has several — so the model never stops to ask the user which one.list_linked_prsorders newest link first, tie-breaking on stored append order rather thanlinkedAt, which two links can share.link_prnormally loses the race toAppSettings.automaticallyLinkPullRequests, which links a pull request the moment its URL appears in a transcript message — including the message asking for the link — soalready_linkedis the usual outcome, not a duplicate request. The result message and the tool description both say so; do not "fix" the status.- A GitHub reachability failure surfaces as
pullRequestUnavailable, not as a persistence error.Alveary/ViewModels/PullRequests/Links/AGENTS.mdowns howPullRequestLinkService.linkvalidates. - The initial prompt is fire-and-forget through the ordinary first-send path.
AppComponent+Threads.startHeadlessInitialPrompttakes a background lease and callssetupAndStart, so a created thread creates its worktree, spawns, auto-names, and gets ordinary host-tool exposure exactly like one the user typed into. The tool claims dispatch, never an outcome; a spawn failure leaves a retryable first message on the new thread. startHeadlessInitialPromptis shared with the pull request pane's agentic threads, which differ only in the prompt they dispatch. Keep it prompt-only: their guidance reaches the agent through an instructions tool, not through a longer signature here.send_prompt_to_threadawaits the target's queue-or-send decision, unlikecreate_thread's fire-and-forget:sentandqueuedare different answers, and a refusal at the target must reach the model as an error. It claims dispatch, never the outcome, and keeps a receipt ascreate_threaddoes.- A conversation cannot send to its own thread; the prompt would only queue behind the calling turn.
- A relayed row is the prompt; its sender rides beside it as
relayedFromConversationId/relayedFromThreadName, rendered as a note above the bubble, while the transport header carries thethread_ida reply needs and makes answering conditional. It goes throughqueueOrSendRelayedPrompt, neverqueueOrSend, so the target's staged composer state stays put. - Loops are refused by content, never by count. Threads are meant to talk unattended, so
relayLoopCheckrefuses only an echo of the counterpart's latest relay or theidenticalRelayLimitth copy of one prompt to it since the user last typed; the self-send refusal alone cannot stop two threads answering each other. HeadlessRelayedPromptDeliveryhydrates an idle target's transcript before sending and holds its lease until the queue is empty and the turn ends. An empty grouper's cursor would skip unprocessed history, and a queued prompt drains only under an active controller lifecycle.