@jeffober/thatch 0.1.42 → 0.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/thatch +281 -47
- package/docs/dev/README.md +9 -1
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +18 -11
- package/docs/dev/features/cross-session-chat.md +124 -40
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/dev/skills.md +1 -0
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +19 -2
- package/docs/user/cli.md +35 -37
- package/docs/user/cross-session-chat.md +92 -19
- package/docs/user/skills.md +1 -0
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +376 -147
- package/src/commands.ts +107 -0
- package/src/db.ts +54 -5
- package/src/debug.ts +44 -0
- package/src/git.ts +22 -0
- package/src/index.ts +264 -40
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +30 -2
- package/src/setup.ts +203 -33
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +107 -27
- package/src/watchers.ts +51 -29
package/README.md
CHANGED
|
@@ -99,13 +99,22 @@ Thatch gives your agent:
|
|
|
99
99
|
(opencode, Claude Code, and Cursor; same machine). opencode sessions
|
|
100
100
|
join the directory automatically under an assigned, never-reused name
|
|
101
101
|
(a slug of the session title plus a counter); others see the roster, and
|
|
102
|
-
messages land in inboxes -- opencode sessions get woken with a
|
|
103
|
-
|
|
102
|
+
messages land in inboxes -- idle opencode sessions get woken with a
|
|
103
|
+
prompt, and Cursor and Claude Code wake when a turn ends with unread
|
|
104
|
+
mail (Cursor also at prompt time; Claude Code also at startup and
|
|
105
|
+
resume). Loop-safe:
|
|
104
106
|
messages are informational to the receiving agent, and wake prompts are
|
|
105
107
|
rate-capped. Delivered messages are framed as untrusted content so a
|
|
106
108
|
hostile message cannot impersonate your instructions. Disable the whole
|
|
107
109
|
feature with `chat.enabled: false` in the thatch config (or just the
|
|
108
110
|
auto-joining with `chat.autoRegister: false`).
|
|
111
|
+
- **Wrap-up commands** (opencode only) -- `/thatch/compact` and `/thatch/exit`
|
|
112
|
+
run a pre-flight checklist before a compaction or an exit: the agent flushes
|
|
113
|
+
pending fact extraction, finishes promised memory writes, and surfaces
|
|
114
|
+
todos or follow-ups it never addressed. It ends its response with a
|
|
115
|
+
greenlight token only when the checklist is clean; thatch then triggers the
|
|
116
|
+
compaction or quits opencode. With items outstanding, the agent lists them
|
|
117
|
+
and nothing fires -- you decide when to retry.
|
|
109
118
|
- **Notifications + user config** -- the agent can ping you out-of-band when a
|
|
110
119
|
long-running outcome lands: a desktop banner, a spoken voice
|
|
111
120
|
announcement, or both (macOS and Linux). Preferences live in a
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: thatch-clear-writing
|
|
3
|
+
description: Prose rules for every piece of text a human reads that no dedicated skill already covers - PR and ticket comments posted on the user's behalf, documentation, feature plans, reports, write-ups, status updates, and chat replies to the user. Load it before drafting any of those. When a dedicated writing skill is loaded (thatch-pr-description, thatch-ticket-description, thatch-review-response), that skill's rules win.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clear writing
|
|
7
|
+
|
|
8
|
+
## Core principle
|
|
9
|
+
|
|
10
|
+
Write for humans. Not for LLMs. Not to impress. Not to pad.
|
|
11
|
+
|
|
12
|
+
Every sentence earns its place or gets cut. Plain English over jargon. Concrete over abstract. If a phrase sounds smart but you cannot say it in simpler words, it is a buzzword. Cut or replace it.
|
|
13
|
+
|
|
14
|
+
Target a 7th-grade reading level. Short sentences, common words, one idea each. The concepts stay technical; the language stays simple.
|
|
15
|
+
|
|
16
|
+
## Where these rules apply
|
|
17
|
+
|
|
18
|
+
Every text a human reads:
|
|
19
|
+
|
|
20
|
+
- **Comments you post for the user** - PR comments, ticket comments (Linear, Jira), replies to reviewers, replies to bot findings.
|
|
21
|
+
- **Written artifacts** - documentation, feature plans, reports, write-ups, design notes, status updates.
|
|
22
|
+
- **Replies to the user in chat** - explanations, summaries, answers.
|
|
23
|
+
|
|
24
|
+
Same prose rules everywhere. Only the structure differs:
|
|
25
|
+
|
|
26
|
+
- **Artifacts** may use sections, headings, and bullets as the content needs.
|
|
27
|
+
- **Chat replies** use no scaffold. Lead with the answer, then the context, then the caveats. Short, but never compressed into shorthand to make it short.
|
|
28
|
+
|
|
29
|
+
A dedicated writing skill overrides this one when loaded: `thatch-pr-description` for PR bodies, `thatch-ticket-description` for ticket descriptions, `thatch-review-response` for replies on the user's own PR, the review skills for review reports. Use those where they fit. This skill covers everything they do not.
|
|
30
|
+
|
|
31
|
+
## The reader
|
|
32
|
+
|
|
33
|
+
A busy engineer with zero context on this task. They know the stack and general engineering vocabulary. They have never touched this area. They read once, top to bottom, and every sentence must parse on the first read.
|
|
34
|
+
|
|
35
|
+
## Ordering
|
|
36
|
+
|
|
37
|
+
Lead with the answer. Then the evidence. Then the caveats.
|
|
38
|
+
|
|
39
|
+
The reader decides when to stop reading. Give them the point first so stopping early is safe. Never build to a reveal.
|
|
40
|
+
|
|
41
|
+
- First sentence or two: the conclusion, the answer, or the request.
|
|
42
|
+
- Then: why. The mechanism, the evidence, the reasoning.
|
|
43
|
+
- Last: caveats, open questions, alternatives considered.
|
|
44
|
+
|
|
45
|
+
## Prose rules
|
|
46
|
+
|
|
47
|
+
Hard rules. Not suggestions.
|
|
48
|
+
|
|
49
|
+
1. **One idea per sentence.** Split clause-chains. One topic per paragraph.
|
|
50
|
+
2. **Literal phrasing beats compressed idiom.** Write for an unfamiliar reader. Do not use method names as verbs. Prefer plain English nouns over code-domain nouns: "reading the row from the database" not "the row read"; "a request waiting for a worker" not "a waiter". If a word sounds like it belongs in a source file rather than a sentence, replace it.
|
|
51
|
+
3. **Define subsystem-specific terms on first use.** General engineering vocabulary needs no definition. "the DEK (the symmetric key that encrypts secrets at rest)" - yes. After the first definition, use the term freely.
|
|
52
|
+
4. **Translate project-private labels.** Roadmap names, subsystem nicknames, ticket shorthand, and branch-local labels are not explanations. Say what the code does first: what reads, writes, blocks, retries, or changes. The label can follow if it helps the reader connect prose to code.
|
|
53
|
+
5. **Prefer concrete over ambiguous.** If a word can mean more than one thing here, qualify it or replace it with the specific behavior. Ordinary words can be ambiguous when the code gives them a special role: claim, owner, active, current, publish, resolve, sync, scope. "Persist the value in the database so the UI can display it" beats "publish the value".
|
|
54
|
+
6. **Explain operations before naming them.** Refresh, repair, coalesce, reconcile, normalize, hydrate, fan out - these are not explanations. Name the object, the action, and the effect: "The worker waits up to 5 seconds, then reloads each stale cache key once."
|
|
55
|
+
7. **Name both sides of a contrast.** If timing, ownership, or behavior changed, state the old behavior and the new close together.
|
|
56
|
+
8. **Show causal links.** If a sentence uses "so", "because", or "prevents", make the middle step visible. Cause and effect that do not obviously touch need the missing step spelled out.
|
|
57
|
+
9. **Clarity wins over compression.** Brevity means fewer claims, not denser claims. Shorten by deleting whole claims. Never rewrite surviving sentences into tighter ones. Keep the connective tissue that makes a sentence parse on first read.
|
|
58
|
+
10. **No invented shorthand.** Do not coin abbreviations, portmanteaus, or labels mid-text. Use the real name, or spell out the behavior. A term the reader cannot find anywhere else is a term they cannot look up.
|
|
59
|
+
11. **No buzzwordy abstractions.** "leverage" -> "use". "utilize" -> "use". If you cannot say what a phrase means in plain English, it does not belong.
|
|
60
|
+
12. **Plain ASCII.** No smart quotes, em dashes, or ellipsis glyphs.
|
|
61
|
+
13. **No process narration.** Never describe the order you assembled things in or narrate your own actions inside the text. State the thing itself.
|
|
62
|
+
|
|
63
|
+
## Length
|
|
64
|
+
|
|
65
|
+
Match length to complexity: how much new mental model the reader must build.
|
|
66
|
+
|
|
67
|
+
- A one-line answer gets one line.
|
|
68
|
+
- A real explanation gets as many sentences as the idea needs, and not one more.
|
|
69
|
+
- When over length, cut the lowest-value claim. Do not compress the survivors.
|
|
70
|
+
|
|
71
|
+
For chat replies: terse is fine, illegible is not. If a two-line answer only fits by inventing shorthand, spend five lines.
|
|
72
|
+
|
|
73
|
+
## Clarity pass
|
|
74
|
+
|
|
75
|
+
Before you post, save, or send: reread the draft as a reader with zero context. Fix what they would stumble on. Do not announce the pass; just return clean prose.
|
|
76
|
+
|
|
77
|
+
Check: Does every sentence parse on first read? Does every term carry its plain-English meaning before or with the label? Would a reader who has never seen this codebase, ticket, or conversation understand it? If the draft runs long, cut a claim; never tighten the grammar.
|
|
@@ -48,6 +48,7 @@ After all changes are complete, verify in this order:
|
|
|
48
48
|
- Simple fixes: fix immediately.
|
|
49
49
|
- Complex fixes: plan as a separate milestone.
|
|
50
50
|
4. **Run tests, linters, and formatters.** Address any issues they surface. If the project has a quality gate (make check, npm run check, mise run check), run it.
|
|
51
|
+
5. **Repo finish gate.** If the current repo defines its own definition-of-done skill (in the thatch repo: `thatch-dev-done`), load it and work its checklist before claiming the task complete. The generic verification above does not cover repo-specific duties: QA use cases, doc tiers, count touch points, commit conventions.
|
|
51
52
|
|
|
52
53
|
## Blockers vs intermediate states
|
|
53
54
|
|
package/bin/thatch
CHANGED
|
@@ -8,10 +8,12 @@ import { detectRepo } from "../src/git";
|
|
|
8
8
|
import { BgeEmbeddingModel } from "../src/embeddings";
|
|
9
9
|
import { hygieneReport } from "../src/hygiene";
|
|
10
10
|
import { claudeSessionStartReminder } from "../src/prompts";
|
|
11
|
-
import { setupClaudeCode, setupCursor } from "../src/setup";
|
|
11
|
+
import { setupClaudeCode, setupCursor, registerClaudeMcpServer } from "../src/setup";
|
|
12
12
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
|
|
13
|
-
import { chatTailDiff, chatTailBacklog, filterChatTailRows,
|
|
13
|
+
import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailJsonl, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, CHAT_STALE_MS, CHAT_RENUDGE_MINUTES, chatLiveness, humanAge, splitChatRoster, mcpSessionID, isoMinutesAgo, renderChatParticipant, resolveRegisteredPredecessor, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
|
|
14
14
|
import { chatEnabled, loadConfig } from "../src/config";
|
|
15
|
+
import { chatNotificationNudge } from "../src/prompts";
|
|
16
|
+
import { createDebugLog } from "../src/debug";
|
|
15
17
|
|
|
16
18
|
const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
17
19
|
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
@@ -34,6 +36,7 @@ function usage(): never {
|
|
|
34
36
|
thatch buffer-batch Append a PostToolBatch payload to the queue (Claude Code hook only)
|
|
35
37
|
thatch buffer-tool Append a single postToolUse interaction to the queue (Cursor hook only)
|
|
36
38
|
thatch flush-tools [--json] Peek the queue + print an extraction nudge (hook only)
|
|
39
|
+
thatch chat-notify Print a chat follow-up message when mail is unread (Cursor stop hook)
|
|
37
40
|
thatch flush-predictions [--json] Query predictions for the prompt (hook only)
|
|
38
41
|
thatch setup --claude [--cursor] [--local|--global]
|
|
39
42
|
Install config + instructions + hooks + skills
|
|
@@ -46,11 +49,14 @@ function usage(): never {
|
|
|
46
49
|
Full conversation as OpenAI chat-completions JSONL
|
|
47
50
|
thatch session search <query> [--session <id>] [--regex] [--limit N]
|
|
48
51
|
JSONL of matching parts across sessions (decoded content)
|
|
49
|
-
thatch chat list Registered chat sessions
|
|
52
|
+
thatch chat list Registered chat sessions, grouped Active/Stale:
|
|
53
|
+
name, age, status, project, topic
|
|
50
54
|
thatch chat tail [--once] [--limit N|all] [--match RE]
|
|
51
55
|
[--from NAME] [--to NAME] [--since DT] [--until DT]
|
|
52
|
-
Follow cross-session chat
|
|
53
|
-
|
|
56
|
+
Follow cross-session chat as a JSONL
|
|
57
|
+
event log: one line per event, "sent"
|
|
58
|
+
and "read" as separate events linked by
|
|
59
|
+
message id. The backlog shows the last
|
|
54
60
|
${CHAT_TAIL_DEFAULT_LIMIT} messages
|
|
55
61
|
(--limit N or --limit all to override).
|
|
56
62
|
--match filters message bodies
|
|
@@ -81,7 +87,7 @@ async function defaultStore(): Promise<string> {
|
|
|
81
87
|
* session ID it keeps the old shape: a line only when pending mail exists
|
|
82
88
|
* for registered sessions in the project, naming the addressees.
|
|
83
89
|
*/
|
|
84
|
-
async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
90
|
+
async function chatHookLine(hookSessionID?: string, transcriptPath?: string): Promise<string> {
|
|
85
91
|
// Disabled chat prints nothing: the hook line must not advertise a
|
|
86
92
|
// feature the user turned off (the tools refuse, the poller never
|
|
87
93
|
// starts, the prompt section omits chat).
|
|
@@ -99,6 +105,25 @@ async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
|
99
105
|
// the MCP identity anchor - the session ID is stable per conversation,
|
|
100
106
|
// so re-registering finds the same row and name.
|
|
101
107
|
const sessionID = mcpSessionID(hookSessionID);
|
|
108
|
+
// Continuation adoption: Claude Code forks the session id on resume
|
|
109
|
+
// (and has been observed to fork spontaneously), stranding the old
|
|
110
|
+
// identity and mailbox. When this session is unknown, look for a
|
|
111
|
+
// predecessor transcript handing off to it; adopting keeps the name
|
|
112
|
+
// and mail following the human-level conversation. Runs before the
|
|
113
|
+
// ensure so the migrated row is the one re-registered.
|
|
114
|
+
if (!db.findChatSession(sessionID) && transcriptPath) {
|
|
115
|
+
try {
|
|
116
|
+
const pred = resolveRegisteredPredecessor(transcriptPath, hookSessionID, (raw) =>
|
|
117
|
+
db.findChatSession(mcpSessionID(raw))?.session_id ?? null,
|
|
118
|
+
);
|
|
119
|
+
if (pred) {
|
|
120
|
+
db.continueChatSession(pred, sessionID);
|
|
121
|
+
hookDebug("hook:adoption", `continued chat identity: ${pred} -> ${sessionID}`);
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error(`[thatch] chat continuation adoption failed: ${err}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
102
127
|
// The leave tombstone blocks this ensure: chat_unregister must outlive
|
|
103
128
|
// the turn that issued it, on MCP hosts as on opencode. The line is
|
|
104
129
|
// the standing signal - it repeats every prompt until the model
|
|
@@ -129,6 +154,61 @@ async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
|
129
154
|
return `[thatch] chat: unread message(s) for ${parts.join(", ")} - the addressee should call chat_read`;
|
|
130
155
|
}
|
|
131
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Reads the hook session ID from stdin without hanging when no payload is
|
|
159
|
+
* coming. Interactive runs skip the read outright (isTTY); a piped or
|
|
160
|
+
* inherited stdin that never closes (bare spawns) gives up after a short
|
|
161
|
+
* grace period. Hook hosts write the payload immediately at spawn, so the
|
|
162
|
+
* grace window costs nothing on the hot path. Malformed payloads resolve to
|
|
163
|
+
* undefined rather than failing the reminder.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Reads the hook's stdin JSON without hanging when no payload is coming.
|
|
167
|
+
* Interactive runs skip the read outright (isTTY); a piped or inherited
|
|
168
|
+
* stdin that never closes (bare spawns) gives up after a short grace
|
|
169
|
+
* period and destroys the stream, because a pending stdin read keeps
|
|
170
|
+
* the process alive after main finishes. Hook hosts write the payload
|
|
171
|
+
* immediately at spawn, so the grace window costs nothing on the hot path.
|
|
172
|
+
* Malformed payloads resolve to undefined rather than failing the command.
|
|
173
|
+
* Uses the Node stream API instead of Bun.stdin.text(): a pending
|
|
174
|
+
* Bun.stdin read cannot be cancelled and pins the process forever.
|
|
175
|
+
*/
|
|
176
|
+
function readHookInput(tag: string): Promise<Record<string, unknown> | undefined> {
|
|
177
|
+
if (process.stdin.isTTY) return Promise.resolve(undefined);
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const chunks: Buffer[] = [];
|
|
180
|
+
const timer = setTimeout(() => done(undefined), 500);
|
|
181
|
+
const done = (value: string | undefined) => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
process.stdin.destroy();
|
|
184
|
+
try {
|
|
185
|
+
if (!value) return resolve(undefined);
|
|
186
|
+
logHookPayload(tag, value);
|
|
187
|
+
resolve(JSON.parse(value) as Record<string, unknown>);
|
|
188
|
+
} catch {
|
|
189
|
+
resolve(undefined);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
193
|
+
process.stdin.on("end", () => done(Buffer.concat(chunks).toString()));
|
|
194
|
+
process.stdin.on("error", () => done(undefined));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const hookDebug = createDebugLog(dbPath);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Debug-logs one hook invocation's raw stdin payload (tag hook:<command>,
|
|
202
|
+
* payload truncated). Answers "did the host fire the hook at all, and what
|
|
203
|
+
* did it send?" - the chat DB cannot distinguish a hook that never fired
|
|
204
|
+
* from one whose payload carried no session id, and host hook contracts
|
|
205
|
+
* are sometimes documented wrong.
|
|
206
|
+
*/
|
|
207
|
+
function logHookPayload(command: string, raw: string): void {
|
|
208
|
+
const clipped = raw.length > 2000 ? `${raw.slice(0, 2000)}...` : raw;
|
|
209
|
+
hookDebug(`hook:${command}`, `stdin: ${JSON.stringify(clipped)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
132
212
|
/**
|
|
133
213
|
* Emits the nudge output, prepending a version warning if present. The
|
|
134
214
|
* warning goes first so the LLM sees it before the nudge text. Used by
|
|
@@ -324,15 +404,56 @@ async function sessionCommand(rest: string[]): Promise<void> {
|
|
|
324
404
|
// chat subcommand group - read-only window on the cross-session chat
|
|
325
405
|
// ---------------------------------------------------------------------------
|
|
326
406
|
|
|
327
|
-
/**
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
407
|
+
/** Column headers for the `chat list` roster, in display order. */
|
|
408
|
+
const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Renders the `chat list` roster as two sections - Active and Stale - each
|
|
412
|
+
* an aligned-column table under a header row. Section headers are bold,
|
|
413
|
+
* the stale explainer is muted italic; rows use green names, dim age,
|
|
414
|
+
* green/red status, and a muted italic topic.
|
|
415
|
+
* Color is applied only when stdout is an interactive terminal (and
|
|
416
|
+
* NO_COLOR is unset), so piped output - grep, redirect, QA runners - stays
|
|
417
|
+
* clean plain text.
|
|
418
|
+
*/
|
|
419
|
+
function formatChatRoster(sessions: ChatSessionRow[]): string {
|
|
420
|
+
const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
421
|
+
const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
422
|
+
const { active, stale } = splitChatRoster(sessions);
|
|
423
|
+
const renderTable = (rows: ChatSessionRow[]) => {
|
|
424
|
+
const cells = (r: ChatSessionRow) => [
|
|
425
|
+
r.name,
|
|
426
|
+
humanAge(r.last_seen),
|
|
427
|
+
chatLiveness(r),
|
|
428
|
+
r.project ?? "unknown",
|
|
429
|
+
r.topic ?? "",
|
|
430
|
+
];
|
|
431
|
+
// Widths come from the PLAIN cell text; color is applied after padding
|
|
432
|
+
// so zero-width escape codes never skew the alignment.
|
|
433
|
+
const widths = CHAT_ROSTER_HEADER.map((h, i) => Math.max(h.length, ...rows.map((r) => cells(r)[i].length)));
|
|
434
|
+
const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
|
|
435
|
+
const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
|
|
436
|
+
const body = rows.map((r) => {
|
|
437
|
+
const [name, age, status, project, topic] = align(cells(r));
|
|
438
|
+
return [
|
|
439
|
+
paint("32", name),
|
|
440
|
+
paint("2", age),
|
|
441
|
+
paint(status === "stale" ? "31" : "32", status),
|
|
442
|
+
project,
|
|
443
|
+
topic ? paint("3;90", topic) : "",
|
|
444
|
+
].join(" ").trimEnd();
|
|
445
|
+
});
|
|
446
|
+
return [header, ...body].join("\n");
|
|
447
|
+
};
|
|
448
|
+
const parts: string[] = [];
|
|
449
|
+
if (active.length > 0) {
|
|
450
|
+
parts.push([paint("1", "# Active Sessions"), renderTable(active)].join("\n"));
|
|
451
|
+
}
|
|
452
|
+
if (stale.length > 0) {
|
|
453
|
+
const explainer = `These sessions have missed their heartbeat for over ${Math.round(CHAT_STALE_MS / 1000)} seconds. Their harnesses may no longer be running (stopped, or the machine slept); mail waits until they resume.`;
|
|
454
|
+
parts.push([paint("1", "# Stale Sessions"), paint("3;90", explainer), renderTable(stale)].join("\n"));
|
|
455
|
+
}
|
|
456
|
+
return parts.join("\n\n");
|
|
336
457
|
}
|
|
337
458
|
|
|
338
459
|
async function chatCommand(rest: string[]): Promise<void> {
|
|
@@ -345,26 +466,14 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
345
466
|
console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
|
|
346
467
|
break;
|
|
347
468
|
}
|
|
348
|
-
|
|
349
|
-
// Same staleness convention as the chat_list tool: an age past the
|
|
350
|
-
// threshold means the session's host process is probably gone.
|
|
351
|
-
const liveness = isStale(s, CHAT_STALE_MINUTES) ? "STALE" : "fresh";
|
|
352
|
-
const parts = [
|
|
353
|
-
s.name,
|
|
354
|
-
`(${humanAge(s.last_seen)})`,
|
|
355
|
-
liveness,
|
|
356
|
-
`project:${s.project ?? "unknown"}`,
|
|
357
|
-
];
|
|
358
|
-
if (s.topic) parts.push(s.topic);
|
|
359
|
-
console.log(parts.join(" "));
|
|
360
|
-
}
|
|
469
|
+
console.log(formatChatRoster(sessions));
|
|
361
470
|
break;
|
|
362
471
|
}
|
|
363
472
|
|
|
364
473
|
case "tail": {
|
|
365
474
|
const onceOnly = restArgs.includes("--once");
|
|
366
475
|
// Parse and validate everything up front: a bad regex or date must
|
|
367
|
-
// fail before any
|
|
476
|
+
// fail before any lines print, not mid-follow.
|
|
368
477
|
const matches = flagValues(restArgs, "--match").map((pattern) => {
|
|
369
478
|
try {
|
|
370
479
|
return new RegExp(pattern);
|
|
@@ -415,16 +524,15 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
415
524
|
// each poll emits only new messages and newly-read rows. The diff
|
|
416
525
|
// and backlog logic lives in src/chat.ts (chatTailDiff,
|
|
417
526
|
// chatTailBacklog), unit-tested there. The backlog seeds the state
|
|
418
|
-
// from every feed row, so the limit hides
|
|
527
|
+
// from every feed row, so the limit hides lines, not history.
|
|
419
528
|
let state: Map<number, string | null>;
|
|
420
|
-
let firstFollowEvent = true;
|
|
421
529
|
|
|
422
530
|
// Follow mode holds the handle open until Ctrl-C. The finally below
|
|
423
531
|
// closes it on every exit path except the SIGINT handler's, which
|
|
424
532
|
// is why the handler closes it itself: process.exit does not unwind
|
|
425
533
|
// finally blocks (closing twice is harmless).
|
|
426
534
|
try {
|
|
427
|
-
// First pass:
|
|
535
|
+
// First pass: print the backlog, then follow unless
|
|
428
536
|
// --once or the --until window has already closed (a closed
|
|
429
537
|
// window has no future events to follow). The backlog fetch gets
|
|
430
538
|
// the same friendly failure treatment as follow polls: a locked
|
|
@@ -439,13 +547,13 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
439
547
|
}
|
|
440
548
|
state = backlog.state;
|
|
441
549
|
if (backlog.elided > 0) {
|
|
442
|
-
// The elision note goes to stderr so stdout stays
|
|
443
|
-
//
|
|
550
|
+
// The elision note goes to stderr so stdout stays pure JSONL
|
|
551
|
+
// for pipes.
|
|
444
552
|
console.error(
|
|
445
|
-
`[thatch chat tail] showing last ${backlog.
|
|
553
|
+
`[thatch chat tail] showing last ${backlog.shown} of ${backlog.elided + backlog.shown} messages (--limit N, --limit all)`,
|
|
446
554
|
);
|
|
447
555
|
}
|
|
448
|
-
|
|
556
|
+
for (const event of backlog.events) console.log(formatChatTailJsonl(event));
|
|
449
557
|
if (onceOnly || (filter.untilMs !== null && filter.untilMs <= Date.now())) break;
|
|
450
558
|
|
|
451
559
|
process.on("SIGINT", () => {
|
|
@@ -458,10 +566,7 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
458
566
|
// Follow polls re-filter the live feed: matching messages
|
|
459
567
|
// surface as they arrive, non-matching ones stay invisible.
|
|
460
568
|
for (const event of chatTailDiff(state, filterChatTailRows(db.chatMessageFeed(), filter)).events) {
|
|
461
|
-
|
|
462
|
-
if (firstFollowEvent && backlog.events.length > 0) console.log(`\n${CHAT_TAIL_SEPARATOR}\n`);
|
|
463
|
-
firstFollowEvent = false;
|
|
464
|
-
console.log(formatChatTailCard(event));
|
|
569
|
+
console.log(formatChatTailJsonl(event));
|
|
465
570
|
}
|
|
466
571
|
} catch (err) {
|
|
467
572
|
// The surface's whole job is watching a shared, mutable file;
|
|
@@ -597,10 +702,28 @@ switch (command) {
|
|
|
597
702
|
} catch (err) {
|
|
598
703
|
console.error(`[thatch] hygiene report failed: ${err}`);
|
|
599
704
|
}
|
|
600
|
-
// Chat mail check.
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
705
|
+
// Chat mail check. SessionStart DOES pass a session id in the hook's
|
|
706
|
+
// stdin JSON (session_id for Claude Code, conversation_id for Cursor),
|
|
707
|
+
// so a hooked session learns its identity at session start, not only at
|
|
708
|
+
// its first prompt. When no payload is coming (manual runs, bare
|
|
709
|
+
// spawns) the read is skipped and the line keeps the old shape: a line
|
|
710
|
+
// only when pending mail exists for registered sessions in this project.
|
|
711
|
+
const hookInput = await readHookInput("reminder");
|
|
712
|
+
const hookSessionID = (hookInput?.session_id as string | undefined) ?? (hookInput?.conversation_id as string | undefined);
|
|
713
|
+
const chatLine = await chatHookLine(hookSessionID, hookInput?.transcript_path as string | undefined);
|
|
714
|
+
// Claude Code identity anchor: hooks and the MCP server are children of
|
|
715
|
+
// the same claude process, so recording (parent pid -> session) lets the
|
|
716
|
+
// server resolve the caller without trusting the model's `as`. Recorded
|
|
717
|
+
// only for payloads with session_id (Claude Code) - Cursor's hooks come
|
|
718
|
+
// from a shared extension host where ppid is ambiguous. CLAUDE_CODE_
|
|
719
|
+
// SESSION_ID env in the server process is the spawn-time fallback.
|
|
720
|
+
if (hookInput?.session_id && chatLine) {
|
|
721
|
+
try {
|
|
722
|
+
db.recordChatHostPid(process.ppid, mcpSessionID(String(hookInput.session_id)));
|
|
723
|
+
} catch (err) {
|
|
724
|
+
console.error(`[thatch] chat host-pid anchor failed: ${err}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
604
727
|
const reminder = claudeSessionStartReminder(repo, hygiene) + (chatLine ? `\n\n${chatLine}` : "");
|
|
605
728
|
if (isJson) {
|
|
606
729
|
console.log(JSON.stringify({ additional_context: reminder }));
|
|
@@ -710,8 +833,20 @@ switch (command) {
|
|
|
710
833
|
printSkills(result);
|
|
711
834
|
console.log();
|
|
712
835
|
if (result.mcpAddCommand) {
|
|
713
|
-
|
|
714
|
-
|
|
836
|
+
// Global setup cannot write ~/.claude.json directly, so run the
|
|
837
|
+
// registration on the user's behalf. Idempotent: an existing
|
|
838
|
+
// registration is left alone; a missing or failing claude CLI
|
|
839
|
+
// falls back to printing the manual command.
|
|
840
|
+
const reg = await registerClaudeMcpServer(thatchBin);
|
|
841
|
+
if (reg.status === "registered") {
|
|
842
|
+
console.log(` MCP server: ${reg.detail}`);
|
|
843
|
+
} else if (reg.status === "already-registered") {
|
|
844
|
+
console.log(` MCP server: already registered (claude mcp get thatch to inspect)`);
|
|
845
|
+
} else {
|
|
846
|
+
console.log("To register the MCP server globally, run:");
|
|
847
|
+
console.log(` ${result.mcpAddCommand}`);
|
|
848
|
+
console.log(` (${reg.detail})`);
|
|
849
|
+
}
|
|
715
850
|
console.log();
|
|
716
851
|
}
|
|
717
852
|
console.log("Restart Claude Code for the changes to take effect.");
|
|
@@ -749,6 +884,7 @@ switch (command) {
|
|
|
749
884
|
// is not delayed waiting for an exit code-driven message.
|
|
750
885
|
try {
|
|
751
886
|
const raw = await Bun.stdin.text();
|
|
887
|
+
logHookPayload("buffer-batch", raw);
|
|
752
888
|
const data = JSON.parse(raw) as { session_id?: string; tool_calls?: unknown[] };
|
|
753
889
|
if (data.session_id && Array.isArray(data.tool_calls)) {
|
|
754
890
|
const { appendBatch } = await import("../src/extract-queue");
|
|
@@ -769,6 +905,7 @@ switch (command) {
|
|
|
769
905
|
// so we try the most likely candidates.
|
|
770
906
|
try {
|
|
771
907
|
const raw = await Bun.stdin.text();
|
|
908
|
+
logHookPayload("buffer-tool", raw);
|
|
772
909
|
const data = JSON.parse(raw) as {
|
|
773
910
|
conversation_id?: string;
|
|
774
911
|
session_id?: string;
|
|
@@ -813,9 +950,11 @@ switch (command) {
|
|
|
813
950
|
const isJson = args.includes("--json");
|
|
814
951
|
try {
|
|
815
952
|
const raw = await Bun.stdin.text();
|
|
953
|
+
logHookPayload("flush-tools", raw);
|
|
816
954
|
const data = JSON.parse(raw) as {
|
|
817
955
|
session_id?: string;
|
|
818
956
|
conversation_id?: string;
|
|
957
|
+
transcript_path?: string;
|
|
819
958
|
prompt?: string;
|
|
820
959
|
message?: string;
|
|
821
960
|
text?: string;
|
|
@@ -844,7 +983,17 @@ switch (command) {
|
|
|
844
983
|
// Chat check: with a hook session ID this always prints the caller's
|
|
845
984
|
// assigned identity (and its unread count), so the model knows who it
|
|
846
985
|
// is before any chat tool call; with no mail the line says so.
|
|
847
|
-
const chatLine = await chatHookLine(sessionID);
|
|
986
|
+
const chatLine = await chatHookLine(sessionID, data.transcript_path);
|
|
987
|
+
// Claude Code identity anchor (see reminder case): record the
|
|
988
|
+
// parent-process mapping for session_id-bearing (Claude Code)
|
|
989
|
+
// payloads; Cursor's conversation_id stays on the `as` path.
|
|
990
|
+
if (data.session_id && chatLine && sessionID) {
|
|
991
|
+
try {
|
|
992
|
+
db.recordChatHostPid(process.ppid, mcpSessionID(sessionID));
|
|
993
|
+
} catch (err) {
|
|
994
|
+
console.error(`[thatch] chat host-pid anchor failed: ${err}`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
848
997
|
|
|
849
998
|
// Tier 1: extraction nudge. Peek (don't drain) - the queue persists
|
|
850
999
|
// until the agent calls memory_remember, which triggers consumeQueue.
|
|
@@ -916,6 +1065,91 @@ switch (command) {
|
|
|
916
1065
|
break;
|
|
917
1066
|
}
|
|
918
1067
|
|
|
1068
|
+
case "chat-notify": {
|
|
1069
|
+
// Stop hook for BOTH MCP hosts; the payload shape tells them apart:
|
|
1070
|
+
// Claude Code sends session_id (+ stop_hook_active, background_tasks),
|
|
1071
|
+
// Cursor sends conversation_id (+ status, loop_count).
|
|
1072
|
+
//
|
|
1073
|
+
// Output shapes: Cursor consumes { followup_message } (auto-submitted
|
|
1074
|
+
// as the next user message); Claude Code consumes hookSpecificOutput.
|
|
1075
|
+
// additionalContext on Stop - a reminder that continues the turn,
|
|
1076
|
+
// rendered as hook feedback rather than a hook error. Pointer-only:
|
|
1077
|
+
// sender names and a count, never bodies (bodies flow only through
|
|
1078
|
+
// the framed chat_read). The nudge text carries the system-
|
|
1079
|
+
// notification framing because a follow-up arrives as user input.
|
|
1080
|
+
try {
|
|
1081
|
+
const hookInput = await readHookInput("chat-notify");
|
|
1082
|
+
if (!chatEnabled(loadConfig(dbPath).config)) {
|
|
1083
|
+
console.log("{}");
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
const claudeID = typeof hookInput?.session_id === "string" && hookInput.session_id ? String(hookInput.session_id) : null;
|
|
1087
|
+
const cursorID = typeof hookInput?.conversation_id === "string" && hookInput.conversation_id ? String(hookInput.conversation_id) : null;
|
|
1088
|
+
|
|
1089
|
+
// Host-specific loop guards.
|
|
1090
|
+
if (claudeID) {
|
|
1091
|
+
// Never fight the host's loop protections: a stop-hook continuation
|
|
1092
|
+
// already in flight ends the turn, and a session paused on
|
|
1093
|
+
// background work gets its wake when that work's turn ends -
|
|
1094
|
+
// injecting now would interject mid-flight (the mid-turn injection
|
|
1095
|
+
// incident class).
|
|
1096
|
+
if (hookInput?.stop_hook_active === true) {
|
|
1097
|
+
console.log("{}");
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
if (Array.isArray(hookInput?.background_tasks) && hookInput.background_tasks.length > 0) {
|
|
1101
|
+
console.log("{}");
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
} else if (cursorID) {
|
|
1105
|
+
// Cursor: never auto-continue an aborted or errored turn (the
|
|
1106
|
+
// user's stop key wins).
|
|
1107
|
+
if (hookInput?.status !== "completed") {
|
|
1108
|
+
console.log("{}");
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
} else {
|
|
1112
|
+
console.log("{}");
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Lookup, not ensure-register: registration exists by the time stop
|
|
1117
|
+
// fires (the prompt hooks anchored the conversation), and minting
|
|
1118
|
+
// rows from contexts we do not understand is how the throwaway
|
|
1119
|
+
// sessionStart identities happened.
|
|
1120
|
+
const anchor = mcpSessionID(claudeID ?? cursorID!);
|
|
1121
|
+
if (!db.findChatSession(anchor)) {
|
|
1122
|
+
console.log("{}");
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
const pending = db
|
|
1126
|
+
.pendingChatNotifications([anchor], isoMinutesAgo(CHAT_RENUDGE_MINUTES))
|
|
1127
|
+
.filter((m) => m.to_session === anchor);
|
|
1128
|
+
if (pending.length === 0) {
|
|
1129
|
+
console.log("{}");
|
|
1130
|
+
break;
|
|
1131
|
+
}
|
|
1132
|
+
const senders = [
|
|
1133
|
+
...new Set(pending.map((m) => renderChatParticipant(m.from_name, m.from_session))),
|
|
1134
|
+
];
|
|
1135
|
+
// Stamp before emitting: the re-nudge window (not the model's
|
|
1136
|
+
// obedience) bounds repeats when the mail goes ignored.
|
|
1137
|
+
db.markChatDelivered(pending.map((m) => m.id));
|
|
1138
|
+
const nudge = chatNotificationNudge(senders, pending.length);
|
|
1139
|
+
console.log(
|
|
1140
|
+
JSON.stringify(
|
|
1141
|
+
claudeID
|
|
1142
|
+
? { hookSpecificOutput: { hookEventName: "Stop", additionalContext: nudge } }
|
|
1143
|
+
: { followup_message: nudge },
|
|
1144
|
+
),
|
|
1145
|
+
);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
console.error(`[thatch chat-notify] failed: ${err}`);
|
|
1148
|
+
console.log("{}");
|
|
1149
|
+
}
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
919
1153
|
case "flush-predictions": {
|
|
920
1154
|
// UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor) hook.
|
|
921
1155
|
// Standalone prediction fire: queries the sideband for scored
|
package/docs/dev/README.md
CHANGED
|
@@ -90,6 +90,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
90
90
|
| `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
|
|
91
91
|
| `seed-behaviors.ts` | Default behavior seeding on first run. Populates starter self-discipline rules. |
|
|
92
92
|
| `vector-math.ts` | `blobToVector` and `cosineSimilarity` helpers used across the codebase. |
|
|
93
|
+
| `debug.ts` | Opt-in diagnostic logging (`THATCH_DEBUG`) to `debug.log` beside the database, never to the host's stdout/stderr (invisible inside the TUI; would leak into the quality gate from tests). Lines are `<iso> [feature:aspect] msg`; the env var is `1`/`all`/`*` for everything or a comma-separated list of tag filters matching a whole tag or its feature part. Add a tagged line wherever a live harness might need to answer "what did the plugin actually see here?" - the first consumer is `chat:startup`, which records the resolved `-s` session and every registration guard. |
|
|
93
94
|
|
|
94
95
|
## Plugin hooks
|
|
95
96
|
|
|
@@ -99,6 +100,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
99
100
|
|------|-------------|
|
|
100
101
|
| `experimental.chat.system.transform` | Appends the thatch system prompt (store names, usage rules). |
|
|
101
102
|
| `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
|
|
103
|
+
| `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
|
|
102
104
|
| `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
|
|
103
105
|
| `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
|
|
104
106
|
| `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
|
|
@@ -337,7 +339,7 @@ behavior cycle (agent-driven, self-graded)
|
|
|
337
339
|
`behaviors(id PK, store, statement, rationale, embedding BLOB, model, confidence REAL, confirm_count REAL, disconfirm_count REAL, created_at, updated_at)`,
|
|
338
340
|
`behavior_edges(matcher_id, behavior_id, weight REAL, PK(matcher_id, behavior_id), FK CASCADE)`,
|
|
339
341
|
`behavior_provenance(id PK, behavior_id, signal, detail, created_at, FK CASCADE)`,
|
|
340
|
-
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto)`,
|
|
342
|
+
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto, worktree)`,
|
|
341
343
|
`chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at, via_broadcast)`,
|
|
342
344
|
`chat_name_counters(base PK, next)` - per-base counters for assigned chat names; only ever increments.
|
|
343
345
|
`chat_leave_tombstones(session_id PK, left_at)` - explicit leaves; suppresses auto-registration until chat_register rejoins.
|
|
@@ -381,6 +383,12 @@ alone to check docs without the test suite. Run `mise run typecheck` for
|
|
|
381
383
|
`tsc` alone (uses `tsconfig.check.json`, which includes the test files that
|
|
382
384
|
the build's `tsconfig.json` excludes).
|
|
383
385
|
|
|
386
|
+
To watch what a live harness's plugin actually sees at init, launch opencode
|
|
387
|
+
with `THATCH_DEBUG=chat` (or `=1` for every tag) and tail
|
|
388
|
+
`~/.config/thatch/debug.log`. The env var must be set on the `opencode`
|
|
389
|
+
process itself: the plugin runs in a worker thread that inherits the
|
|
390
|
+
process environment but not its argv.
|
|
391
|
+
|
|
384
392
|
## Release
|
|
385
393
|
|
|
386
394
|
```bash
|
|
@@ -25,7 +25,7 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
|
|
|
25
25
|
| [deduplication.md](deduplication.md) | Deduplication system (find pairs, verdict lifecycle) |
|
|
26
26
|
| [sideband.md](sideband.md) | Sideband IPC (Unix socket for MCP host hook processes) |
|
|
27
27
|
| [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
|
|
28
|
-
| [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking) |
|
|
28
|
+
| [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking, wrap-up commands) |
|
|
29
29
|
| [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
|
|
30
30
|
| [cross-session-chat.md](cross-session-chat.md) | Cross-session chat (shared SQLite directory and inbox, per-process wake-up delivery) |
|
|
31
31
|
| [notifications.md](notifications.md) | Out-of-band notification (banner + voice) and the user config file |
|