@100xprompt/brain 0.0.0-stage → 0.1.0

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.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,323 @@
1
+ // `brain gateway login`: sign in to 100xprompt in the browser, the same way the 100xprompt CLI does (`100xprompt auth login`).
2
+ //
3
+ // 1. A one-use server listens on 127.0.0.1 on a free port, for /auth/callback only.
4
+ // 2. The browser opens https://100xprompt.com/auth/cli?port=<port>&state=<random>&challenge=<PKCE S256>. The person signs in
5
+ // there; the site sends the browser back to http://127.0.0.1:<port>/auth/callback?token=...&state=...&email=...
6
+ // 3. The state must be the one sent (so no other page can hand this machine a key); the token is the gateway key.
7
+ // 4. No browser here (SSH), or the callback cannot get through: the person pastes the one-time code or API key the
8
+ // site shows instead (input hidden). It is checked the way the CLI checks it.
9
+ //
10
+ // HUNDREDXPROMPT_AUTH_URL points the sign-in at another site (the CLI reads the same variable). The key is never printed.
11
+ //
12
+ // The state is the only thing that tells the real sign-in apart from a key planted by someone else on this machine, so
13
+ // it is kept out of other processes' view where the system allows: on macOS the address goes to the browser through
14
+ // AppleScript's `open location` on stdin, not on a command line that `ps` shows every user. (Linux and Windows browsers
15
+ // take the address on their command line; there, another user who watches `ps` during the sign-in could plant a key. The
16
+ // 100xprompt CLI has the same limit everywhere.)
17
+ import { timingSafeEqual } from "node:crypto"
18
+
19
+ export const AUTH_URL = "https://100xprompt.com"
20
+ export const TIMEOUT_MS = 5 * 60_000
21
+
22
+ /** PKCE (RFC 7636): a 43-character verifier and its S256 challenge, as the CLI makes them. */
23
+ export async function pkce(): Promise<{ verifier: string; challenge: string }> {
24
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
25
+ const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (b) => chars[b % chars.length]).join("")
26
+ const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")
27
+ return { verifier, challenge }
28
+ }
29
+
30
+ /**
31
+ * Whether a pasted credential looks like a 100xprompt one, as the CLI decides it (Auth.validate): a 100x_live_ / 100x_test_ /
32
+ * sk- key, a sign-in token (JWT, not expired), or a 100x_auth_ one-time code wrapping either. Returns the key to store.
33
+ */
34
+ export function checkCredential(raw: string, now = Date.now()): { ok: true; key: string; email?: string } | { ok: false; why: string } {
35
+ const t = raw.trim()
36
+ if (!t || t.length < 16) return { ok: false, why: "that is too short to be a key or code" }
37
+ if (/\s/.test(t)) return { ok: false, why: "a key or code has no spaces in it" }
38
+ // The sign-in page shows the key shortened ("100x_live_AbCdEfGhIjKl...12345678"); only its Copy key button gives all of it.
39
+ if (/\.\.\.|…/.test(t)) return { ok: false, why: "that is the shortened key the page shows: use its Copy key button, then paste again" }
40
+ if (/^(?:100x_live_|100x_test_|sk-)/.test(t)) {
41
+ if (!/^(?:100x_live_|100x_test_|sk-)[A-Za-z0-9_-]+$/.test(t)) return { ok: false, why: "that key has characters a 100xprompt key never has" }
42
+ return t.length >= 24 ? { ok: true, key: t } : { ok: false, why: "that key is too short" }
43
+ }
44
+ const parts = t.split(".")
45
+ if (parts.length === 3) {
46
+ try {
47
+ const head = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8"))
48
+ const body = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"))
49
+ if (!head?.alg || !body?.sub) return { ok: false, why: "that sign-in token is malformed" }
50
+ if (typeof body.exp === "number" && body.exp < Math.floor(now / 1000)) return { ok: false, why: "that sign-in token has expired: sign in again" }
51
+ return { ok: true, key: t, ...(typeof body.email === "string" ? { email: body.email } : {}) }
52
+ } catch {
53
+ return { ok: false, why: "that sign-in token is malformed" }
54
+ }
55
+ }
56
+ if (t.startsWith("100x_auth_")) {
57
+ try {
58
+ const inner = JSON.parse(Buffer.from(t.slice("100x_auth_".length), "base64url").toString("utf8"))
59
+ if (typeof inner?.t === "string" && !inner.t.startsWith("100x_auth_")) {
60
+ const r = checkCredential(inner.t, now)
61
+ return r.ok && typeof inner.e === "string" && !r.email ? { ...r, email: inner.e } : r
62
+ }
63
+ } catch {}
64
+ return { ok: false, why: "that one-time code is malformed" }
65
+ }
66
+ return { ok: false, why: `that is not a 100xprompt key or one-time code (copy the code from ${AUTH_URL}/auth/cli, or a 100x_live_ key)` }
67
+ }
68
+
69
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
70
+ const page = (title: string, body: string, status = 200) =>
71
+ new Response(
72
+ `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>` +
73
+ `<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#050505;color:#f1ecec}` +
74
+ `main{text-align:center;max-width:32rem;padding:1rem}h1{font-size:1.4rem}p{color:#a8a3a3}</style></head>` +
75
+ `<body><main><h1>${esc(title)}</h1><p>${esc(body)}</p></main></body></html>`,
76
+ // The callback URL carries the key: nothing on this page may cache it or pass it on.
77
+ { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-frame-options": "DENY" } },
78
+ )
79
+
80
+ /** Constant-time state check. Byte lengths are compared (a non-ASCII state of the right length in characters must not throw). */
81
+ const sameState = (got: string, want: string) => {
82
+ const a = Buffer.from(got)
83
+ const b = Buffer.from(want)
84
+ return a.length === b.length && timingSafeEqual(a, b)
85
+ }
86
+
87
+ /** The key in a callback's token: the CLI takes it as it is; brain also unwraps a one-time code and refuses junk. */
88
+ const callbackKey = (token: string): { ok: true; key: string; email?: string } | { ok: false; why: string } =>
89
+ token.startsWith("100x_auth_") ? checkCredential(token) : token.length >= 16 && !/\s/.test(token) ? { ok: true, key: token } : { ok: false, why: "no key came back from the sign-in page" }
90
+
91
+ /** The sign-in site from --auth-url / $HUNDREDXPROMPT_AUTH_URL (empty means unset, as for the CLI); undefined if not http(s). */
92
+ export function signInBase(given?: string): string | undefined {
93
+ const raw = given?.trim() || process.env.HUNDREDXPROMPT_AUTH_URL?.trim() || AUTH_URL
94
+ try {
95
+ const u = new URL(raw)
96
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined
97
+ return `${u.origin}${u.pathname.replace(/\/+$/, "")}`
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ export interface SignInDeps {
104
+ /** Opens the sign-in page; default: the system browser. */
105
+ open?: (url: string) => void
106
+ /** false: print the address only (a machine with no browser, or one the person opens elsewhere). */
107
+ browser?: boolean
108
+ /** The sign-in site; default $HUNDREDXPROMPT_AUTH_URL or https://100xprompt.com. */
109
+ authUrl?: string
110
+ timeoutMs?: number
111
+ /**
112
+ * Asks for a pasted code, input hidden; resolves undefined when the person gives up or `signal` aborts. Absent when
113
+ * there is no terminal to ask in (then the browser is the only way).
114
+ */
115
+ ask?: (message: string, signal: AbortSignal) => Promise<string | undefined>
116
+ say: (line: string) => void
117
+ }
118
+
119
+ export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
120
+
121
+ /** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
122
+ export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
123
+ const base = signInBase(d.authUrl)
124
+ if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
125
+ const { challenge } = await pkce()
126
+ const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
127
+ let finish!: (r: SignIn) => void
128
+ const done = new Promise<SignIn>((r) => (finish = r))
129
+ let settled = false
130
+ const settle = (r: SignIn) => {
131
+ if (settled) return
132
+ settled = true
133
+ finish(r)
134
+ }
135
+ const server = Bun.serve({
136
+ hostname: "127.0.0.1",
137
+ port: 0,
138
+ development: false,
139
+ // Anything the handler did not expect: a plain refusal, never a stack trace on the page or in the terminal.
140
+ error: () => new Response("Bad request", { status: 400 }),
141
+ fetch(req) {
142
+ const url = new URL(req.url)
143
+ if (url.pathname !== "/auth/callback" || req.method !== "GET") return new Response("Not found", { status: 404 })
144
+ if (settled) return page("Already signed in", "This sign-in has finished. You can close this tab.", 410)
145
+ const got = url.searchParams.get("state") ?? ""
146
+ const error = url.searchParams.get("error")
147
+ // Only the page this command opened knows the state: anything else is refused and the wait goes on.
148
+ if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", "This link was not started by this brain gateway login. Start again from your terminal.", 400)
149
+ if (error) {
150
+ settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
151
+ return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
152
+ }
153
+ const c = callbackKey(url.searchParams.get("token") ?? "")
154
+ if (!c.ok) {
155
+ settle({ ok: false, why: c.why })
156
+ return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
157
+ }
158
+ const email = url.searchParams.get("email") || c.email || undefined
159
+ settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
160
+ return page("Signed in", `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.`)
161
+ },
162
+ })
163
+ const url = `${base}/auth/cli?port=${server.port}&state=${state}&challenge=${challenge}`
164
+ const stopAsking = new AbortController()
165
+ const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
166
+ try {
167
+ if (d.browser === false) d.say(`Open this address in a browser to sign in to 100xprompt:\n ${url}`)
168
+ else {
169
+ d.say(`Opening your browser to sign in to 100xprompt:\n ${url}`)
170
+ try {
171
+ ;(d.open ?? openInBrowser)(url)
172
+ } catch {}
173
+ }
174
+ if (d.ask) {
175
+ d.say(
176
+ "Waiting for the browser. Signing in on another machine (SSH)? Paste the key from the page's Copy key button, or the address the browser could not open, then press Enter (Escape cancels).",
177
+ )
178
+ // The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
179
+ // (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
180
+ void (async () => {
181
+ for (;;) {
182
+ const pasted = await d.ask!("key or address: ", stopAsking.signal)
183
+ if (settled) return
184
+ if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
185
+ if (!pasted.trim()) continue
186
+ const c = fromPaste(pasted.trim(), state)
187
+ if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
188
+ if ("fatal" in c) return settle({ ok: false, why: c.why })
189
+ d.say(` ${c.why}`)
190
+ }
191
+ })()
192
+ } else d.say("Waiting for the browser…")
193
+ return await done
194
+ } finally {
195
+ clearTimeout(timer)
196
+ stopAsking.abort()
197
+ // The page that just answered must reach the browser before this returns: `brain` exits right after, and an exit
198
+ // with the answer still unsent leaves the person looking at a browser error after a sign-in that worked. A graceful
199
+ // stop waits for that answer; a browser holding the connection open gets cut off after 1.5 s.
200
+ await Promise.race([server.stop(), Bun.sleep(1_500)])
201
+ void server.stop(true)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * A pasted key, one-time code or sign-in token; or the callback address itself (on SSH the browser is sent to
207
+ * 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
208
+ * state, so one from another sign-in, or one made up, is refused.
209
+ */
210
+ export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
211
+ if (/^https?:\/\//i.test(pasted)) {
212
+ let u: URL
213
+ try {
214
+ u = new URL(pasted)
215
+ } catch {
216
+ return { ok: false, why: "that address could not be read" }
217
+ }
218
+ if (!u.pathname.endsWith("/auth/callback")) return { ok: false, why: "that is not the sign-in's return address (it ends in /auth/callback?token=...)" }
219
+ const got = u.searchParams.get("state") ?? ""
220
+ if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
221
+ const error = u.searchParams.get("error")
222
+ if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
223
+ const c = callbackKey(u.searchParams.get("token") ?? "")
224
+ if (!c.ok) return c
225
+ const email = u.searchParams.get("email") || c.email
226
+ return { ok: true, key: c.key, ...(email ? { email } : {}) }
227
+ }
228
+ return checkCredential(pasted)
229
+ }
230
+
231
+ /**
232
+ * How to open the sign-in page on this platform: the command, and what to write to its stdin. On macOS the address goes
233
+ * to AppleScript's `open location` on stdin, so it (and its state) is on no command line.
234
+ */
235
+ export function browserCommand(url: string, platform: string = process.platform): { argv: string[]; stdin?: string } {
236
+ if (platform === "darwin") return { argv: ["osascript", "-"], stdin: `open location "${url.replace(/[\\"]/g, (c) => `\\${c}`)}"\n` }
237
+ if (platform === "win32") return { argv: ["cmd", "/c", "start", "", url.replace(/&/g, "^&")] }
238
+ return { argv: ["xdg-open", url] }
239
+ }
240
+
241
+ /** Opens the sign-in page in the default browser (browserCommand). */
242
+ export function openInBrowser(url: string): void {
243
+ const c = browserCommand(url)
244
+ try {
245
+ const p = Bun.spawn(c.argv, { stdin: c.stdin === undefined ? "ignore" : "pipe", stdout: "ignore", stderr: "ignore" })
246
+ if (c.stdin !== undefined && p.stdin && typeof p.stdin !== "number") {
247
+ p.stdin.write(c.stdin)
248
+ void p.stdin.end()
249
+ }
250
+ p.unref()
251
+ } catch {}
252
+ }
253
+
254
+ /**
255
+ * Applies typed or pasted terminal input to `buf`. Enter ends the line; Escape on its own, or Ctrl-D on an empty line,
256
+ * cancels; Ctrl-C interrupts. Arrow, Delete and Home keys, Alt+key and bracketed-paste markers arrive as escape sequences
257
+ * and are skipped, so they neither end the prompt nor land in the key.
258
+ */
259
+ export function applyKeys(buf: string, chunk: string): { buf: string; end?: "enter" | "cancel" | "interrupt" } {
260
+ for (let i = 0; i < chunk.length; i++) {
261
+ const ch = chunk[i]!
262
+ if (ch === "\r" || ch === "\n") return { buf, end: "enter" }
263
+ if (ch === "\u0003") return { buf, end: "interrupt" }
264
+ if (ch === "\u0004") {
265
+ if (!buf) return { buf, end: "cancel" }
266
+ continue
267
+ }
268
+ if (ch === "\u001b") {
269
+ const next = chunk[i + 1]
270
+ if (next === undefined) return { buf, end: "cancel" }
271
+ if (next === "[") {
272
+ // CSI: parameter bytes, then one final byte in 0x40-0x7E.
273
+ let j = i + 2
274
+ while (j < chunk.length && !(chunk.charCodeAt(j) >= 0x40 && chunk.charCodeAt(j) <= 0x7e)) j++
275
+ i = j
276
+ } else i += next === "O" ? 2 : 1 // SS3 (ESC O x) or Alt+key (ESC x)
277
+ continue
278
+ }
279
+ if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1)
280
+ else if (ch >= " ") buf += ch
281
+ }
282
+ return { buf }
283
+ }
284
+
285
+ /**
286
+ * Reads one line from the terminal without showing it (the key must not land in the scrollback). undefined: no terminal,
287
+ * cancelled (Escape, Ctrl-D), or `signal` aborted. Ctrl-C ends the command, as it would anywhere else.
288
+ */
289
+ export function askHidden(message: string, signal: AbortSignal): Promise<string | undefined> {
290
+ const stdin = process.stdin
291
+ if (!stdin.isTTY || signal.aborted) return Promise.resolve(undefined)
292
+ return new Promise((resolve) => {
293
+ let buf = ""
294
+ let over = false
295
+ process.stderr.write(message)
296
+ stdin.setRawMode(true)
297
+ stdin.setEncoding("utf8")
298
+ stdin.resume()
299
+ const end = (v: string | undefined) => {
300
+ if (over) return
301
+ over = true
302
+ stdin.off("data", onData)
303
+ signal.removeEventListener("abort", onAbort)
304
+ stdin.setRawMode(false)
305
+ stdin.pause()
306
+ process.stderr.write("\n")
307
+ resolve(v)
308
+ }
309
+ const onAbort = () => end(undefined)
310
+ const onData = (chunk: string) => {
311
+ const r = applyKeys(buf, chunk)
312
+ buf = r.buf
313
+ if (r.end === "enter") end(buf)
314
+ else if (r.end === "cancel") end(undefined)
315
+ else if (r.end === "interrupt") {
316
+ end(undefined)
317
+ process.exit(130)
318
+ }
319
+ }
320
+ stdin.on("data", onData)
321
+ signal.addEventListener("abort", onAbort)
322
+ })
323
+ }
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
+ }