@100xprompt/brain 0.0.0-stage → 0.1.1
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/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
// Pending handoffs: a brief waiting for the next session of a target tool in a project. `brain session
|
|
5
|
+
// continue --in <tool>` writes one; the tool's session-start hook (hooks.ts) finds it, hands the brief to
|
|
6
|
+
// the model once, and marks it used for that tool. Kept as plain files under ~/.brain/handoffs.
|
|
7
|
+
|
|
8
|
+
export const HANDOFF_HOURS = 12
|
|
9
|
+
|
|
10
|
+
export interface Handoff {
|
|
11
|
+
id: string
|
|
12
|
+
createdAt: string
|
|
13
|
+
expiresAt: string
|
|
14
|
+
/** The tool and portable id of the session being continued. */
|
|
15
|
+
fromTool: string
|
|
16
|
+
sessionId: string
|
|
17
|
+
title?: string
|
|
18
|
+
/** The project folder the brief is for. */
|
|
19
|
+
cwd: string
|
|
20
|
+
gitRemote?: string
|
|
21
|
+
/** The tool the person is moving to, or "any". */
|
|
22
|
+
target: string
|
|
23
|
+
brief: string
|
|
24
|
+
/** The brief as a Markdown file, for pasting by hand. */
|
|
25
|
+
briefPath: string
|
|
26
|
+
/** Tools whose sessions have already received it. */
|
|
27
|
+
consumedBy: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const handoffDir = (home: string) => join(home, ".brain", "handoffs")
|
|
31
|
+
|
|
32
|
+
export function saveHandoff(home: string, h: Omit<Handoff, "briefPath" | "consumedBy" | "expiresAt" | "createdAt"> & { now?: number }): Handoff {
|
|
33
|
+
const now = h.now ?? Date.now()
|
|
34
|
+
const dir = handoffDir(home)
|
|
35
|
+
mkdirSync(dir, { recursive: true })
|
|
36
|
+
const briefPath = join(dir, `${h.id}.md`)
|
|
37
|
+
const { now: _, ...rest } = h
|
|
38
|
+
const full: Handoff = { ...rest, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + HANDOFF_HOURS * 3_600_000).toISOString(), briefPath, consumedBy: [] }
|
|
39
|
+
writeFileSync(briefPath, `${h.brief}\n`)
|
|
40
|
+
writeFileSync(join(dir, `${h.id}.json`), `${JSON.stringify(full, null, 1)}\n`)
|
|
41
|
+
return full
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listHandoffs(home: string): Handoff[] {
|
|
45
|
+
const dir = handoffDir(home)
|
|
46
|
+
let names: string[] = []
|
|
47
|
+
try {
|
|
48
|
+
names = readdirSync(dir).filter((n) => n.endsWith(".json"))
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
const out: Handoff[] = []
|
|
53
|
+
for (const n of names) {
|
|
54
|
+
try {
|
|
55
|
+
out.push(JSON.parse(readFileSync(join(dir, n), "utf8")) as Handoff)
|
|
56
|
+
} catch {
|
|
57
|
+
// A half-written or foreign file is not a handoff.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const norm = (p: string) => p.replace(/\/+$/, "")
|
|
64
|
+
/** The same project: the same folder, one inside the other, or the same git remote. */
|
|
65
|
+
function sameProject(h: Handoff, cwd: string, gitRemote?: string): boolean {
|
|
66
|
+
const a = norm(h.cwd)
|
|
67
|
+
const b = norm(cwd)
|
|
68
|
+
return a === b || b.startsWith(`${a}/`) || a.startsWith(`${b}/`) || (!!gitRemote && !!h.gitRemote && h.gitRemote === gitRemote)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A tool's claim on a handoff: created exclusively, so two sessions starting at once cannot both take it. */
|
|
72
|
+
const claimPath = (home: string, id: string, tool: string) => join(handoffDir(home), `${id}.${tool}.claimed`)
|
|
73
|
+
|
|
74
|
+
/** The newest unexpired handoff for this project that `tool` has not received yet. */
|
|
75
|
+
export function findHandoff(home: string, q: { cwd: string; tool: string; gitRemote?: string; now?: number }): Handoff | undefined {
|
|
76
|
+
const now = q.now ?? Date.now()
|
|
77
|
+
return listHandoffs(home).find(
|
|
78
|
+
(h) =>
|
|
79
|
+
Date.parse(h.expiresAt) > now &&
|
|
80
|
+
(h.target === q.tool || h.target === "any") &&
|
|
81
|
+
!h.consumedBy.includes(q.tool) &&
|
|
82
|
+
!existsSync(claimPath(home, h.id, q.tool)) &&
|
|
83
|
+
sameProject(h, q.cwd, q.gitRemote),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Claims the handoff for `tool`; false when another session of that tool already has it (deliver nothing then). */
|
|
88
|
+
export function markConsumed(home: string, id: string, tool: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(claimPath(home, id, tool), new Date().toISOString(), { flag: "wx" })
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
// Also recorded in the handoff itself, for listing; the claim file is what decides.
|
|
95
|
+
const path = join(handoffDir(home), `${id}.json`)
|
|
96
|
+
try {
|
|
97
|
+
const h = JSON.parse(readFileSync(path, "utf8")) as Handoff
|
|
98
|
+
if (!h.consumedBy.includes(tool)) h.consumedBy.push(tool)
|
|
99
|
+
writeFileSync(path, `${JSON.stringify(h, null, 1)}\n`)
|
|
100
|
+
} catch {
|
|
101
|
+
// Gone or unreadable: the claim file still stands.
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { CLI_PATH, selfArgs, stableBun } from "./self.ts"
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { toolLabel } from "./brief.ts"
|
|
5
|
+
import { gitInfo, isDir } from "./fsutil.ts"
|
|
6
|
+
import { findHandoff, type Handoff, markConsumed } from "./handoff.ts"
|
|
7
|
+
import { syncInBackground } from "./memory/sync.ts"
|
|
8
|
+
|
|
9
|
+
// Session-start hooks: how a pending handoff (handoff.ts) reaches the model in the target tool without the
|
|
10
|
+
// person pasting anything. Each tool runs `<bun> brain/src/cli.ts session hook session-start --tool <tool>`
|
|
11
|
+
// when a session starts; runHook finds the newest handoff for that folder the tool has not received yet,
|
|
12
|
+
// prints it in the shape the tool expects, and marks it received.
|
|
13
|
+
//
|
|
14
|
+
// Checked against the installed versions on 2026-11-08, from their docs and their own code:
|
|
15
|
+
// - Claude Code 2.1.316: ~/.claude/settings.json hooks.SessionStart [{hooks:[{type,command,timeout}]}];
|
|
16
|
+
// stdout JSON hookSpecificOutput.additionalContext, capped at 10,000 characters.
|
|
17
|
+
// - Codex 0.147.0: ~/.codex/hooks.json (also [hooks] in config.toml; both are merged); hooks are on by
|
|
18
|
+
// default but each must be trusted once by the person (/hooks), and trust is keyed by the hook's position
|
|
19
|
+
// in the file. Plain stdout becomes developer context, spilled to a file above ~2,500 tokens unless the
|
|
20
|
+
// handler sets additionalContextLimit.
|
|
21
|
+
// - Cursor 3.27.22: ~/.cursor/hooks.json {version:1, hooks:{sessionStart:[{command}]}}; stdout JSON
|
|
22
|
+
// additional_context, at most 10,000 characters. Its input has workspace_roots, not cwd. Cursor also runs
|
|
23
|
+
// the hooks in ~/.claude/settings.json, so the Claude Code hook stays quiet when Cursor runs it.
|
|
24
|
+
// - Gemini CLI 0.33.1: ~/.gemini/settings.json hooks.SessionStart (timeouts in milliseconds; user hooks run
|
|
25
|
+
// only in folders Gemini trusts); stdout JSON hookSpecificOutput.additionalContext, injected as the first
|
|
26
|
+
// turn, and systemMessage, shown to the person.
|
|
27
|
+
|
|
28
|
+
type Json = Record<string, any>
|
|
29
|
+
|
|
30
|
+
/** Tools whose session-start hook this module can install. */
|
|
31
|
+
export const HOOK_TOOLS: string[] = ["claude-code", "codex", "cursor-ide", "gemini-cli"]
|
|
32
|
+
|
|
33
|
+
/** The words every installed hook command ends with (before `--tool <tool>`); how uninstall finds its own entries. */
|
|
34
|
+
const MARK = "session hook session-start"
|
|
35
|
+
/** The hook reads a few small files; a stuck one must never hold up a session for long. */
|
|
36
|
+
const TIMEOUT_S = 10
|
|
37
|
+
const CLI = CLI_PATH
|
|
38
|
+
|
|
39
|
+
interface Spec {
|
|
40
|
+
/** The folder the tool creates when it first runs, relative to home; without it there is nothing to hook. */
|
|
41
|
+
dir: string
|
|
42
|
+
/** The user-level config file the hook goes in, relative to home. */
|
|
43
|
+
file: string
|
|
44
|
+
/** The session-start event's key in that file. */
|
|
45
|
+
event: string
|
|
46
|
+
/** Grouped events hold [{matcher?, hooks: [handler]}] (Claude Code, Codex, Gemini CLI); Cursor's hold handlers. */
|
|
47
|
+
grouped: boolean
|
|
48
|
+
handler: (command: string) => Json
|
|
49
|
+
/** Characters of context the tool takes whole from a session-start hook. */
|
|
50
|
+
limit: number
|
|
51
|
+
/** UTF-8 bytes of context the tool takes whole, where it counts bytes rather than characters (Codex). */
|
|
52
|
+
bytes?: number
|
|
53
|
+
/** What the tool expects on stdout: the context for the model, and a line for the person where it shows one. */
|
|
54
|
+
output: (context: string, notice: string) => string
|
|
55
|
+
/** Why the file cannot take the hook as it is, if it cannot. */
|
|
56
|
+
check?: (config: Json) => string | undefined
|
|
57
|
+
/** Fields a new file needs besides the hook. */
|
|
58
|
+
init?: (config: Json) => void
|
|
59
|
+
/** What the person should know about this tool's hooks, from its settings. */
|
|
60
|
+
notes?: (home: string, config: Json) => string[]
|
|
61
|
+
/** Why the tool will not run the hook although it is in the file (its settings turn hooks off), if so. */
|
|
62
|
+
off?: (config: Json) => string | undefined
|
|
63
|
+
/** Hook trust is keyed by position in the file, so removing an entry shifts the ones after it (Codex). */
|
|
64
|
+
positional?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Claude Code and Gemini CLI read the same output shape. */
|
|
68
|
+
const claudeStyle = (context: string, notice: string) => JSON.stringify({ systemMessage: notice, hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } })
|
|
69
|
+
|
|
70
|
+
const SPECS: Record<string, Spec> = {
|
|
71
|
+
"claude-code": {
|
|
72
|
+
dir: ".claude",
|
|
73
|
+
file: ".claude/settings.json",
|
|
74
|
+
event: "SessionStart",
|
|
75
|
+
grouped: true,
|
|
76
|
+
handler: (command) => ({ type: "command", command, timeout: TIMEOUT_S }),
|
|
77
|
+
limit: 9_800,
|
|
78
|
+
output: claudeStyle,
|
|
79
|
+
off: (config) => (config.disableAllHooks === true ? "hooks are turned off in Claude Code's settings (disableAllHooks)" : undefined),
|
|
80
|
+
},
|
|
81
|
+
codex: {
|
|
82
|
+
dir: ".codex",
|
|
83
|
+
file: ".codex/hooks.json",
|
|
84
|
+
event: "SessionStart",
|
|
85
|
+
grouped: true,
|
|
86
|
+
// Codex counts a token as 4 bytes of UTF-8 and spills context over the limit to a temp file, leaving the model a
|
|
87
|
+
// preview: 4,000 tokens is 16,000 bytes, so the brief is cut to fit in bytes as well as characters.
|
|
88
|
+
handler: (command) => ({ type: "command", command, timeout: TIMEOUT_S, additionalContextLimit: 4_000 }),
|
|
89
|
+
limit: 9_000,
|
|
90
|
+
bytes: 15_600,
|
|
91
|
+
output: (context) => context,
|
|
92
|
+
positional: true,
|
|
93
|
+
notes: (home) => {
|
|
94
|
+
const out = ["Codex runs it once you trust it: accept the prompt when Codex starts, or use /hooks"]
|
|
95
|
+
let toml = ""
|
|
96
|
+
try {
|
|
97
|
+
toml = readFileSync(join(home, ".codex", "config.toml"), "utf8")
|
|
98
|
+
} catch {
|
|
99
|
+
// No config.toml: nothing to merge with.
|
|
100
|
+
}
|
|
101
|
+
if (tomlHasHooks(toml)) out.push("config.toml also has hooks; Codex merges both and warns at startup")
|
|
102
|
+
return out
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"cursor-ide": {
|
|
106
|
+
dir: ".cursor",
|
|
107
|
+
file: ".cursor/hooks.json",
|
|
108
|
+
event: "sessionStart",
|
|
109
|
+
grouped: false,
|
|
110
|
+
handler: (command) => ({ command, timeout: TIMEOUT_S }),
|
|
111
|
+
limit: 9_800,
|
|
112
|
+
output: (context) => JSON.stringify({ additional_context: context }),
|
|
113
|
+
check: (config) => (config.version === undefined || config.version === 1 ? undefined : `hooks.json version ${JSON.stringify(config.version)} is not one this was checked against (1)`),
|
|
114
|
+
init: (config) => {
|
|
115
|
+
config.version ??= 1
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
"gemini-cli": {
|
|
119
|
+
dir: ".gemini",
|
|
120
|
+
file: ".gemini/settings.json",
|
|
121
|
+
event: "SessionStart",
|
|
122
|
+
grouped: true,
|
|
123
|
+
// A name lets the person turn it off with hooksConfig.disabled; Gemini's timeout is in milliseconds.
|
|
124
|
+
handler: (command) => ({ type: "command", name: "brain-handoff", command, timeout: TIMEOUT_S * 1000 }),
|
|
125
|
+
limit: 9_800,
|
|
126
|
+
output: claudeStyle,
|
|
127
|
+
notes: (_home, config) => (config.security?.folderTrust?.enabled !== false ? ["Gemini runs it only in folders you have trusted"] : []),
|
|
128
|
+
off: (config) => {
|
|
129
|
+
if (config.hooksConfig?.enabled === false || config.hooks?.enabled === false) return "hooks are turned off in Gemini's settings (hooksConfig.enabled)"
|
|
130
|
+
// Gemini matches hooksConfig.disabled against a hook's name, which is ours.
|
|
131
|
+
const disabled = config.hooksConfig?.disabled
|
|
132
|
+
return Array.isArray(disabled) && disabled.includes("brain-handoff") ? "Gemini's settings turn this hook off (hooksConfig.disabled)" : undefined
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether Codex's config.toml defines hooks of its own. [hooks.state] does not count: it is where Codex records the
|
|
139
|
+
* hooks the person trusted or turned off (ours included), not a hook.
|
|
140
|
+
*/
|
|
141
|
+
function tomlHasHooks(toml: string): boolean {
|
|
142
|
+
if (!toml.trim()) return false
|
|
143
|
+
try {
|
|
144
|
+
const hooks = (Bun.TOML.parse(toml) as Json).hooks
|
|
145
|
+
return !!hooks && typeof hooks === "object" && Object.entries(hooks).some(([k, v]) => k !== "state" && Array.isArray(v) && v.length > 0)
|
|
146
|
+
} catch {
|
|
147
|
+
return /^\s*\[\[?hooks\.(?!state\b)/m.test(toml)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** "cursor" is what `continue --in` calls Cursor; its handoffs are recorded for "cursor-ide". */
|
|
152
|
+
const toolId = (tool: string) => (tool === "cursor" ? "cursor-ide" : tool)
|
|
153
|
+
|
|
154
|
+
const sh = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
155
|
+
|
|
156
|
+
/** The command a tool's hook runs; `program` (default: this bun and cli.ts, or the pinned package: self.ts) is what tests override. */
|
|
157
|
+
/** What a hook starts brain with: this bun, then cli.ts (or, run through bunx, the pinned package: self.ts). */
|
|
158
|
+
export const hookProgram = (cli: string = CLI_PATH) => [stableBun(), ...selfArgs(cli)].map(sh).join(" ")
|
|
159
|
+
|
|
160
|
+
function hookCommand(tool: string, program = hookProgram()): string {
|
|
161
|
+
return `${program} ${MARK} --tool ${tool}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const isOurs = (h: unknown, tool: string) => typeof (h as Json)?.command === "string" && (h as Json).command.trimEnd().endsWith(`${MARK} --tool ${tool}`)
|
|
165
|
+
|
|
166
|
+
// ---- reading and writing the tools' config files ----
|
|
167
|
+
|
|
168
|
+
type Loaded = { config: Json; indent: string } | { error: string }
|
|
169
|
+
|
|
170
|
+
function load(path: string): Loaded {
|
|
171
|
+
if (!existsSync(path)) return { config: {}, indent: " " }
|
|
172
|
+
let text: string
|
|
173
|
+
try {
|
|
174
|
+
text = readFileSync(path, "utf8")
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { error: `cannot read it (${(e as Error).message})` }
|
|
177
|
+
}
|
|
178
|
+
if (!text.trim()) return { config: {}, indent: " " }
|
|
179
|
+
try {
|
|
180
|
+
const v = JSON.parse(text)
|
|
181
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return { error: "it is not a JSON object; left as it is" }
|
|
182
|
+
// Keep the file's own indentation so a rewrite changes only what it must.
|
|
183
|
+
return { config: v, indent: text.match(/\n([ \t]+)"/)?.[1] ?? " " }
|
|
184
|
+
} catch {
|
|
185
|
+
return { error: "it is not plain JSON (comments?); left as it is" }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const backupOf = (path: string) => `${path}.brain-backup`
|
|
190
|
+
|
|
191
|
+
/** Writes atomically through a symlink (dotfile managers), backing up the original once. */
|
|
192
|
+
function save(path: string, config: Json, indent: string) {
|
|
193
|
+
const real = existsSync(path) ? realpathSync(path) : path
|
|
194
|
+
mkdirSync(dirname(real), { recursive: true })
|
|
195
|
+
if (existsSync(real) && !existsSync(backupOf(path))) copyFileSync(real, backupOf(path))
|
|
196
|
+
const tmp = `${real}.brain-tmp`
|
|
197
|
+
let mode: number | undefined
|
|
198
|
+
try {
|
|
199
|
+
mode = statSync(real).mode & 0o777
|
|
200
|
+
} catch {
|
|
201
|
+
// A new file keeps the default mode.
|
|
202
|
+
}
|
|
203
|
+
// Create the copy with the file's own mode: Claude Code's settings are 0600 and can hold API keys, so they must
|
|
204
|
+
// not sit readable by everyone, even for a moment. A copy left by an interrupted run would keep its old mode.
|
|
205
|
+
rmSync(tmp, { force: true })
|
|
206
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, indent)}\n`, mode === undefined ? {} : { mode })
|
|
207
|
+
if (mode !== undefined) chmodSync(tmp, mode)
|
|
208
|
+
renameSync(tmp, real)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface Pos {
|
|
212
|
+
i: number
|
|
213
|
+
j: number
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The event's list in a config, or undefined when the config has none; an error when it is not shaped as expected. */
|
|
217
|
+
function eventList(config: Json, spec: Spec): Json[] | undefined | { error: string } {
|
|
218
|
+
if (config.hooks === undefined) return undefined
|
|
219
|
+
if (!config.hooks || typeof config.hooks !== "object" || Array.isArray(config.hooks)) return { error: `"hooks" is not an object; left as it is` }
|
|
220
|
+
const list = config.hooks[spec.event]
|
|
221
|
+
if (list === undefined) return undefined
|
|
222
|
+
return Array.isArray(list) ? list : { error: `"hooks.${spec.event}" is not a list; left as it is` }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const handlersOf = (entry: Json, spec: Spec): Json[] => (spec.grouped ? (Array.isArray(entry?.hooks) ? entry.hooks : []) : [entry])
|
|
226
|
+
|
|
227
|
+
/** Where this tool's brain hooks sit in the list, in file order. */
|
|
228
|
+
function positions(list: Json[], spec: Spec, tool: string): Pos[] {
|
|
229
|
+
const out: Pos[] = []
|
|
230
|
+
list.forEach((entry, i) => handlersOf(entry, spec).forEach((h, j) => isOurs(h, tool) && out.push({ i, j })))
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function removeAt(list: Json[], spec: Spec, p: Pos) {
|
|
235
|
+
if (!spec.grouped) {
|
|
236
|
+
list.splice(p.i, 1)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
list[p.i]!.hooks.splice(p.j, 1)
|
|
240
|
+
if (!list[p.i]!.hooks.length) list.splice(p.i, 1)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const same = (a: Json, b: Json) => Object.keys(a).length === Object.keys(b).length && Object.keys(b).every((k) => a[k] === b[k])
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether the brain's session-start hook is installed for `tool` under `home` and the tool's settings let it run;
|
|
247
|
+
* brief-writers.ts tells the person to paste the brief by hand when it is not.
|
|
248
|
+
*/
|
|
249
|
+
export function hookInstalled(tool: string, home: string): boolean {
|
|
250
|
+
const id = toolId(tool)
|
|
251
|
+
const spec = SPECS[id]
|
|
252
|
+
if (!spec) return false
|
|
253
|
+
const loaded = load(join(home, spec.file))
|
|
254
|
+
if ("error" in loaded) return false
|
|
255
|
+
const list = eventList(loaded.config, spec)
|
|
256
|
+
return Array.isArray(list) && positions(list, spec, id).length > 0 && !spec.off?.(loaded.config)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface HookChange {
|
|
260
|
+
tool: string
|
|
261
|
+
/** The config file changed (or that would be). */
|
|
262
|
+
path: string
|
|
263
|
+
action: "installed" | "already-installed" | "removed" | "not-installed" | "skipped"
|
|
264
|
+
why?: string
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Checks shared by install and uninstall: a known tool, its folder, a readable config of the expected shape. */
|
|
268
|
+
function prepare(tool: string, home: string): { id: string; spec: Spec; path: string; loaded: Extract<Loaded, { config: Json }>; list: Json[] | undefined } | HookChange {
|
|
269
|
+
const id = toolId(tool)
|
|
270
|
+
const spec = SPECS[id]
|
|
271
|
+
if (!spec) return { tool, path: "", action: "skipped", why: `no session-start hook for "${tool}"; hooks exist for ${HOOK_TOOLS.join(", ")}` }
|
|
272
|
+
const path = join(home, spec.file)
|
|
273
|
+
if (!isDir(join(home, spec.dir))) return { tool: id, path, action: "skipped", why: `no ~/${spec.dir} here: ${toolLabel(id)} has not run on this machine` }
|
|
274
|
+
const loaded = load(path)
|
|
275
|
+
if ("error" in loaded) return { tool: id, path, action: "skipped", why: loaded.error }
|
|
276
|
+
const bad = spec.check?.(loaded.config)
|
|
277
|
+
if (bad) return { tool: id, path, action: "skipped", why: bad }
|
|
278
|
+
const list = eventList(loaded.config, spec)
|
|
279
|
+
if (list && !Array.isArray(list)) return { tool: id, path, action: "skipped", why: list.error }
|
|
280
|
+
return { id, spec, path, loaded, list }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Adds the brain's session-start hook to each tool's user config (with a backup); idempotent. Merges with
|
|
285
|
+
* the person's hooks and always appends its own entry, so the positions of theirs never change. A dry run
|
|
286
|
+
* reports the current state and, in `why`, what an install would do. `command` replaces the program part of
|
|
287
|
+
* the hook command (`<bun> <cli.ts>`); the rest stays, since that is how uninstall finds the entry.
|
|
288
|
+
*/
|
|
289
|
+
export async function installHooks(tools: string[], home: string, opts: { dryRun?: boolean; command?: string } = {}): Promise<HookChange[]> {
|
|
290
|
+
const out: HookChange[] = []
|
|
291
|
+
for (const tool of [...new Set(tools)]) {
|
|
292
|
+
const p = prepare(tool, home)
|
|
293
|
+
if (!("spec" in p)) {
|
|
294
|
+
out.push(p)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
const { id, spec, path, loaded } = p
|
|
298
|
+
const fresh = spec.handler(hookCommand(id, opts.command))
|
|
299
|
+
const off = spec.off?.(loaded.config)
|
|
300
|
+
const notes = [...(off ? [off] : []), ...(spec.notes?.(home, loaded.config) ?? [])]
|
|
301
|
+
const change = (action: HookChange["action"], ...parts: string[]): HookChange => {
|
|
302
|
+
const why = [...parts, ...notes].join("; ")
|
|
303
|
+
return { tool: id, path, action, ...(why ? { why } : {}) }
|
|
304
|
+
}
|
|
305
|
+
const found = p.list ? positions(p.list, spec, id) : []
|
|
306
|
+
const current = found.length ? handlersOf(p.list![found[0]!.i]!, spec)[found[0]!.j]! : undefined
|
|
307
|
+
if (found.length === 1 && same(current!, fresh)) {
|
|
308
|
+
out.push(change("already-installed"))
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
if (opts.dryRun) {
|
|
312
|
+
out.push(found.length ? change("already-installed", "would update it to the current command") : change("not-installed", "would add it"))
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
const config = loaded.config
|
|
316
|
+
if (!found.length) {
|
|
317
|
+
spec.init?.(config)
|
|
318
|
+
config.hooks ??= {}
|
|
319
|
+
config.hooks[spec.event] ??= []
|
|
320
|
+
config.hooks[spec.event].push(spec.grouped ? { hooks: [fresh] } : fresh)
|
|
321
|
+
} else {
|
|
322
|
+
// Update the first in place (keeping its position, which Codex's trust is keyed by) and drop duplicates, last first.
|
|
323
|
+
const list = p.list!
|
|
324
|
+
for (const d of found.slice(1).reverse()) removeAt(list, spec, d)
|
|
325
|
+
if (spec.grouped) list[found[0]!.i]!.hooks[found[0]!.j] = fresh
|
|
326
|
+
else list[found[0]!.i] = fresh
|
|
327
|
+
}
|
|
328
|
+
save(path, config, loaded.indent)
|
|
329
|
+
out.push(found.length ? change("installed", "updated it in place") : change("installed"))
|
|
330
|
+
}
|
|
331
|
+
return out
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Removes exactly what installHooks added. */
|
|
335
|
+
export async function uninstallHooks(tools: string[], home: string, opts: { dryRun?: boolean } = {}): Promise<HookChange[]> {
|
|
336
|
+
const out: HookChange[] = []
|
|
337
|
+
for (const tool of [...new Set(tools)]) {
|
|
338
|
+
const p = prepare(tool, home)
|
|
339
|
+
if (!("spec" in p)) {
|
|
340
|
+
out.push(p)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
const { id, spec, path, loaded, list } = p
|
|
344
|
+
const found = list ? positions(list, spec, id) : []
|
|
345
|
+
if (!found.length) {
|
|
346
|
+
out.push({ tool: id, path, action: "not-installed" })
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
// Codex keys trust by position: entries after ours move up and must be trusted again.
|
|
350
|
+
const shifts = spec.positional && list!.length - 1 > found[0]!.i ? "hooks listed after it move up one place, so Codex will ask you to trust them again" : undefined
|
|
351
|
+
if (opts.dryRun) {
|
|
352
|
+
out.push({ tool: id, path, action: "installed", why: ["would remove it", shifts].filter(Boolean).join("; ") })
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
const config = loaded.config
|
|
356
|
+
for (const d of [...found].reverse()) removeAt(list!, spec, d)
|
|
357
|
+
if (!list!.length) {
|
|
358
|
+
delete config.hooks[spec.event]
|
|
359
|
+
if (!Object.keys(config.hooks).length) delete config.hooks
|
|
360
|
+
}
|
|
361
|
+
// A file that did not exist before install (no backup) and now holds nothing of the person's goes away.
|
|
362
|
+
const leftover = Object.keys(config).filter((k) => !(k === "version" && !spec.grouped))
|
|
363
|
+
if (!leftover.length && !existsSync(backupOf(path))) unlinkSync(path)
|
|
364
|
+
else save(path, config, loaded.indent)
|
|
365
|
+
out.push({ tool: id, path, action: "removed", ...(shifts ? { why: shifts } : {}) })
|
|
366
|
+
}
|
|
367
|
+
return out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---- running as a tool's hook ----
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Which tool is running the hook, from its input: Cursor runs Claude Code's user hooks, so does VS Code's Copilot
|
|
374
|
+
* Chat when chat.useClaudeHooks is on (VS Code 1.144), and Codex's /import copies them.
|
|
375
|
+
*/
|
|
376
|
+
function hostOf(input: Json): string | undefined {
|
|
377
|
+
if (typeof input.cursor_version === "string") return "cursor-ide"
|
|
378
|
+
const path = typeof input.transcript_path === "string" ? input.transcript_path : ""
|
|
379
|
+
// Copilot Chat keeps its transcripts in its own extension storage and starts sessions with source "new", which
|
|
380
|
+
// none of the four tools sends (the Copilot CLI documents the same value).
|
|
381
|
+
if (/\/github\.copilot-chat\//i.test(path) || input.source === "new") return "vscode-copilot"
|
|
382
|
+
const m = path.match(/\/\.(claude|codex|gemini|cursor)\//)
|
|
383
|
+
return m ? ({ claude: "claude-code", codex: "codex", gemini: "gemini-cli", cursor: "cursor-ide" } as Record<string, string>)[m[1]!] : undefined
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The session's folders: `cwd` (Claude Code, Codex, Gemini CLI), else Cursor's workspace roots. Never the hook's own cwd. */
|
|
387
|
+
function foldersOf(input: Json): string[] {
|
|
388
|
+
const all = [input.cwd, ...(Array.isArray(input.workspace_roots) ? input.workspace_roots : [])]
|
|
389
|
+
return [...new Set(all.filter((p): p is string => typeof p === "string" && p.startsWith("/")).map((p) => p.replace(/(.)\/+$/, "$1")))]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function pending(home: string, cwd: string, tool: string): Handoff | undefined {
|
|
393
|
+
const remote = gitInfo(cwd).gitRemote
|
|
394
|
+
const h = findHandoff(home, { cwd, tool, ...(remote ? { gitRemote: remote } : {}) })
|
|
395
|
+
if (!h) return undefined
|
|
396
|
+
// A session opened at the home folder or above it (/Users, /) contains every project below: only an exact match counts.
|
|
397
|
+
const root = home.replace(/\/+$/, "")
|
|
398
|
+
const top = cwd === "/" || cwd === root || root.startsWith(`${cwd}/`)
|
|
399
|
+
return top && h.cwd.replace(/(.)\/+$/, "$1") !== cwd ? undefined : h
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function since(when: string): string {
|
|
403
|
+
const m = Math.round((Date.now() - Date.parse(when)) / 60_000)
|
|
404
|
+
if (!Number.isFinite(m) || m < 2) return "just now"
|
|
405
|
+
return m < 90 ? `${m} minutes ago` : `${Math.round(m / 60)} hours ago`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** `s` cut to at most `chars` characters and `bytes` bytes of UTF-8, marked with "…" where cut; never splits a character. */
|
|
409
|
+
function fit(s: string, chars: number, bytes = Number.POSITIVE_INFINITY): string {
|
|
410
|
+
if (s.length <= chars && Buffer.byteLength(s) <= bytes) return s
|
|
411
|
+
let out = ""
|
|
412
|
+
let size = Buffer.byteLength("…")
|
|
413
|
+
for (const ch of s) {
|
|
414
|
+
const n = Buffer.byteLength(ch)
|
|
415
|
+
if (out.length + ch.length > chars - 1 || size + n > bytes) break
|
|
416
|
+
out += ch
|
|
417
|
+
size += n
|
|
418
|
+
}
|
|
419
|
+
return `${out.trimEnd()}…`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Runs as a tool's hook: reads the hook's JSON input, finds a pending handoff for the session's folder,
|
|
424
|
+
* and returns what the tool expects on stdout (empty when there is nothing to hand over). Never throws:
|
|
425
|
+
* a hook that fails must not get in the way of the session it runs in.
|
|
426
|
+
*/
|
|
427
|
+
export async function runHook(event: string, tool: string, input: string, home: string): Promise<string> {
|
|
428
|
+
try {
|
|
429
|
+
const id = toolId(tool)
|
|
430
|
+
const spec = SPECS[id]
|
|
431
|
+
if (!spec || !/^session-?start$/i.test(event)) return ""
|
|
432
|
+
const parsed = input.trim() ? JSON.parse(input) : {}
|
|
433
|
+
const inp: Json = parsed && typeof parsed === "object" ? parsed : {}
|
|
434
|
+
if (typeof inp.hook_event_name === "string" && !/^session-?start$/i.test(inp.hook_event_name)) return ""
|
|
435
|
+
// After a compaction the session is already under way; a background agent is not the person carrying on.
|
|
436
|
+
if (inp.source === "compact" || inp.is_background_agent === true) return ""
|
|
437
|
+
const host = hostOf(inp)
|
|
438
|
+
if (host && host !== id) return ""
|
|
439
|
+
// Memory catches up on this project's earlier sessions in the background (once memory has been set up; see sync.ts).
|
|
440
|
+
const first = foldersOf(inp)[0]
|
|
441
|
+
if (first) syncInBackground(home, first, CLI)
|
|
442
|
+
for (const cwd of foldersOf(inp)) {
|
|
443
|
+
const h = pending(home, cwd, id)
|
|
444
|
+
if (!h) continue
|
|
445
|
+
const from = `${toolLabel(h.fromTool)}${h.title ? ` ("${fit(h.title.replace(/\s+/g, " ").trim(), 80)}")` : ""}, handed over ${since(h.createdAt)}`
|
|
446
|
+
const context = fit(`Continuing from ${from}.\n\n${h.brief}`, spec.limit, spec.bytes)
|
|
447
|
+
// Claimed before it is printed: another session of this tool that started at the same moment gets nothing.
|
|
448
|
+
if (!markConsumed(home, h.id, id)) continue
|
|
449
|
+
return spec.output(context, `brain: continuing from ${from}; the model has the brief.`)
|
|
450
|
+
}
|
|
451
|
+
return ""
|
|
452
|
+
} catch {
|
|
453
|
+
return ""
|
|
454
|
+
}
|
|
455
|
+
}
|