@jeffober/thatch 0.2.5 → 0.2.7
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 +48 -25
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +11 -0
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +25 -15
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-clarity.md +101 -0
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +22 -2
- package/bin/thatch +103 -33
- package/docs/dev/README.md +11 -8
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +23 -7
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +99 -27
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +41 -15
- package/docs/dev/features/prediction-engine.md +27 -1
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +11 -5
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +3 -3
- package/docs/dev/skills.md +12 -8
- package/docs/user/README.md +22 -14
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +28 -15
- package/docs/user/code-review.md +46 -4
- package/docs/user/commands.md +27 -7
- package/docs/user/cross-session-chat.md +48 -20
- package/docs/user/prediction-engine.md +12 -1
- package/docs/user/skills.md +9 -6
- package/package.json +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +56 -24
- package/src/commands.ts +109 -18
- package/src/db.ts +23 -2
- package/src/mcp.ts +6 -2
- package/src/opencode/tui-plugin.ts +29 -9
- package/src/opencode/v2.ts +247 -66
- package/src/prompts.ts +55 -6
- package/src/runtime.ts +122 -17
- package/src/scoring-engine.ts +40 -9
- package/src/session-tab-shared.ts +55 -4
- package/src/skills.ts +5 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +55 -22
package/src/tool-defs.ts
CHANGED
|
@@ -133,6 +133,13 @@ export interface CoreContext {
|
|
|
133
133
|
* authoritative over the caller-claimed `as` argument. Returns null when
|
|
134
134
|
* no fresh mapping exists (Cursor, unknown server process). */
|
|
135
135
|
chatDerivedIdentity?: () => string | null;
|
|
136
|
+
/** Reads the sessions that have an opencode tab open RIGHT NOW (the
|
|
137
|
+
* per-channel tabs.json reader plus confirmed-close overrides, computed
|
|
138
|
+
* fresh at each call). Roster rows carrying a tab render "open"; fresh
|
|
139
|
+
* rows without one render "recent", and broadcast targets only open
|
|
140
|
+
* rows. Unwired (tests, hosts with no local opencode state) means no
|
|
141
|
+
* tab signal: everything degrades to "recent", never a false "open". */
|
|
142
|
+
openTabs?: () => ReadonlySet<string>;
|
|
136
143
|
/** Host-executed session-tab operations, wired ONLY by the opencode v2
|
|
137
144
|
* adapter (post-construction into the shared CoreContext, because the
|
|
138
145
|
* implementation closes over both the runtime's db and the v2 plugin
|
|
@@ -2131,12 +2138,17 @@ const chatRegisterDef: ToolDef = {
|
|
|
2131
2138
|
};
|
|
2132
2139
|
|
|
2133
2140
|
/** Lists registered sessions with liveness. Stale means the session's host
|
|
2134
|
-
* process has not heartbeat-ed it recently - it is gone or hung.
|
|
2141
|
+
* process has not heartbeat-ed it recently - it is gone or hung. Fresh
|
|
2142
|
+
* rows split into open (a tab is open now) and recent (no known tab).
|
|
2143
|
+
* The three sections render unguarded by design, unlike the CLI's
|
|
2144
|
+
* --recent/--stale guards: an agent choosing a recipient always needs
|
|
2145
|
+
* the full directory, including the tab-less rows it may still reach. */
|
|
2135
2146
|
const chatListDef: ToolDef = {
|
|
2136
2147
|
name: "chat_list",
|
|
2137
2148
|
description:
|
|
2138
2149
|
"List sessions registered in the cross-session chat directory, with a " +
|
|
2139
|
-
"liveness marker (
|
|
2150
|
+
"liveness marker (open = a tab is open now, recent = host alive but no " +
|
|
2151
|
+
"open tab, stale = host likely gone), each session's " +
|
|
2140
2152
|
"project and topic, and your own unread count.",
|
|
2141
2153
|
args: {
|
|
2142
2154
|
as: mcpIdentityArg(),
|
|
@@ -2149,10 +2161,11 @@ const chatListDef: ToolDef = {
|
|
|
2149
2161
|
return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
|
|
2150
2162
|
}
|
|
2151
2163
|
const unread = ctx.db.unreadChatCount(identity.sessionID);
|
|
2152
|
-
const
|
|
2164
|
+
const openTabs = ctx.openTabs?.() ?? new Set<string>();
|
|
2165
|
+
const { active, recent, stale } = splitChatRoster(sortChatRoster(sessions), Date.now(), null, openTabs);
|
|
2153
2166
|
const line = (s: (typeof sessions)[number]) => {
|
|
2154
2167
|
const self = s.session_id === identity.sessionID;
|
|
2155
|
-
const liveness = chatLiveness(s);
|
|
2168
|
+
const liveness = chatLiveness(s, Date.now(), openTabs);
|
|
2156
2169
|
const project = s.project ? ` project:${s.project}` : "";
|
|
2157
2170
|
// The age turns the liveness bucket into a concrete last-check-in;
|
|
2158
2171
|
// the checkout kind tells sessions on a shared tree who sits in a
|
|
@@ -2167,6 +2180,15 @@ const chatListDef: ToolDef = {
|
|
|
2167
2180
|
if (active.length > 0) {
|
|
2168
2181
|
sections.push(`# Active Sessions\n\n${active.map(line).join("\n")}`);
|
|
2169
2182
|
}
|
|
2183
|
+
if (recent.length > 0) {
|
|
2184
|
+
sections.push(
|
|
2185
|
+
`# Recent Sessions\n\n` +
|
|
2186
|
+
`_These sessions are alive but have no open tab: a wake may not reach them ` +
|
|
2187
|
+
`until their tab reopens, though headless subordinates may still read mail ` +
|
|
2188
|
+
`on their own turns._\n\n` +
|
|
2189
|
+
`${recent.map(line).join("\n")}`,
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2170
2192
|
if (stale.length > 0) {
|
|
2171
2193
|
sections.push(
|
|
2172
2194
|
`# Stale Sessions\n\n` +
|
|
@@ -2178,13 +2200,17 @@ const chatListDef: ToolDef = {
|
|
|
2178
2200
|
// The name-stability note closes the roster because it answers the
|
|
2179
2201
|
// question a reader asking "is this the same X as before?" needs:
|
|
2180
2202
|
// assigned names are unique forever; only hook-anchored MCP identities
|
|
2181
|
-
// can be revisited by an old name.
|
|
2203
|
+
// can be revisited by an old name. The liveness legend decodes the
|
|
2204
|
+
// per-row verdicts: presence (open) is distinct from a live host
|
|
2205
|
+
// (recent) and from a stopped one (stale).
|
|
2182
2206
|
return (
|
|
2183
2207
|
`[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n\n` +
|
|
2184
2208
|
`${sections.join("\n\n")}\n\n` +
|
|
2209
|
+
`Liveness: open = the session has an opencode tab right now; recent = ` +
|
|
2210
|
+
`its harness is alive but no tab is open (wake delivery not guaranteed); ` +
|
|
2211
|
+
`stale = its harness has missed its heartbeat and may be gone. ` +
|
|
2185
2212
|
`Names are assigned by thatch and never reused or reassigned, so the ` +
|
|
2186
|
-
`same name is always the same session
|
|
2187
|
-
`idle has not been seen recently.`
|
|
2213
|
+
`same name is always the same session.`
|
|
2188
2214
|
);
|
|
2189
2215
|
},
|
|
2190
2216
|
};
|
|
@@ -2226,17 +2252,21 @@ const chatSendDef: ToolDef = {
|
|
|
2226
2252
|
? `\n\nDUPLICATE SUPPRESSED: an identical message sent moments ago is still UNREAD in the recipient's inbox, so no second copy was created - the original delivery stands. The same body delivers again once they have read it (or a few seconds past the dedupe window).`
|
|
2227
2253
|
: "";
|
|
2228
2254
|
// State the recipient's ACTUAL liveness: the boilerplate "waits until
|
|
2229
|
-
// idle" is only true for a
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2255
|
+
// idle" is only true for a session with an open tab. A recent row's
|
|
2256
|
+
// harness is alive but the tab-less wake can be swallowed without
|
|
2257
|
+
// running a turn - say so instead of promising a wake that may never
|
|
2258
|
+
// fire. A stale recipient's harness is probably gone entirely.
|
|
2232
2259
|
const row = ctx.db.findChatSession(session_id);
|
|
2233
|
-
const liveness = row ? chatLiveness(row) : "stale";
|
|
2260
|
+
const liveness = row ? chatLiveness(row, Date.now(), ctx.openTabs?.()) : "stale";
|
|
2261
|
+
const lastSeen = row ? humanAge(row.last_seen) : "just now";
|
|
2234
2262
|
const delivery =
|
|
2235
|
-
liveness === "
|
|
2236
|
-
? `The recipient is
|
|
2237
|
-
: liveness === "
|
|
2238
|
-
? `
|
|
2239
|
-
:
|
|
2263
|
+
liveness === "open"
|
|
2264
|
+
? `The recipient is open (last seen ${lastSeen}) and will be woken when its session goes idle.`
|
|
2265
|
+
: liveness === "recent"
|
|
2266
|
+
? `NOTE: the recipient is RECENT (last seen ${lastSeen}): its harness is alive, but the session has NO OPEN TAB. The message waits in its inbox, but wake delivery is not guaranteed - a tab-less session may not run a turn on the wake. Daemon-resident sessions (subordinates) usually still pick the message up on their next activity; a closed tab may sit unread. If you expected a live window, check chat_list.`
|
|
2267
|
+
: liveness === "active"
|
|
2268
|
+
? `The recipient is an MCP-host session; it sees this message at its next prompt.`
|
|
2269
|
+
: `NOTE: the recipient is STALE (last seen ${row ? humanAge(row.last_seen) : "unknown"}). Its harness may no longer be running - the message waits unread until that session resumes, and no wake will fire meanwhile. If you meant a different session, check chat_list and resend.`;
|
|
2240
2270
|
return (
|
|
2241
2271
|
`[sent] to ${name} (${session_id.slice(0, 12)})\n\n` +
|
|
2242
2272
|
`${delivery}${splitNote}${dedupeNote}`
|
|
@@ -2344,11 +2374,12 @@ const chatBroadcastDef: ToolDef = {
|
|
|
2344
2374
|
"Broadcast one message to every other registered session on this " +
|
|
2345
2375
|
"machine at once - for announcements and open questions (\"which of " +
|
|
2346
2376
|
"you is working on X?\", \"main just moved, rebase if you are based on " +
|
|
2347
|
-
"it\").
|
|
2348
|
-
"
|
|
2349
|
-
"
|
|
2350
|
-
"
|
|
2351
|
-
"
|
|
2377
|
+
"it\"). Opencode sessions without an open tab (stale, or fresh but " +
|
|
2378
|
+
"tab-less) are skipped and reported: they are not reliably wakeable, so " +
|
|
2379
|
+
"messaging them would spend a turn on a delivery that likely never " +
|
|
2380
|
+
"runs. MCP sessions receive it at their next prompt. Each recipient is " +
|
|
2381
|
+
"woken like any chat message, so broadcast sparingly: every open " +
|
|
2382
|
+
"session spends a model turn on it.",
|
|
2352
2383
|
args: {
|
|
2353
2384
|
body: z.string().describe(
|
|
2354
2385
|
`Message body, delivered to every other registered session. Bodies over ${CHAT_SPLIT_PART_LEN} characters ` +
|
|
@@ -2368,7 +2399,7 @@ const chatBroadcastDef: ToolDef = {
|
|
|
2368
2399
|
`recipients: ${result.recipients.length > 0 ? result.recipients.join(", ") : "(none)"}`,
|
|
2369
2400
|
];
|
|
2370
2401
|
if (result.parts > 1) lines.push(`sent as ${result.parts} parts per recipient (the body exceeded ${CHAT_SPLIT_PART_LEN} characters and was auto-split)`);
|
|
2371
|
-
if (result.skipped.length > 0) lines.push(`skipped stale: ${result.skipped.join(", ")}`);
|
|
2402
|
+
if (result.skipped.length > 0) lines.push(`skipped (stale or no open tab): ${result.skipped.join(", ")}`);
|
|
2372
2403
|
if (result.deduped > 0) lines.push(`duplicate suppressed: an identical broadcast sent moments ago is still UNREAD at ${result.deduped} recipient${result.deduped === 1 ? "" : "s"} - the original delivery stands; the same body delivers again to a recipient once they have read it`);
|
|
2373
2404
|
return (
|
|
2374
2405
|
lines.join("\n") +
|
|
@@ -2625,6 +2656,7 @@ export interface CoreContextExtensions {
|
|
|
2625
2656
|
drainExtractionQueue?: CoreContext["drainExtractionQueue"];
|
|
2626
2657
|
watcherRegistry?: WatcherRegistry;
|
|
2627
2658
|
projectDir?: string;
|
|
2659
|
+
openTabs?: CoreContext["openTabs"];
|
|
2628
2660
|
}
|
|
2629
2661
|
|
|
2630
2662
|
/**
|
|
@@ -2652,6 +2684,7 @@ export function buildCoreContext(
|
|
|
2652
2684
|
drainExtractionQueue: extensions?.drainExtractionQueue,
|
|
2653
2685
|
watchers: extensions?.watcherRegistry,
|
|
2654
2686
|
projectDir: extensions?.projectDir,
|
|
2687
|
+
openTabs: extensions?.openTabs,
|
|
2655
2688
|
};
|
|
2656
2689
|
}
|
|
2657
2690
|
|