dsh-thoughtdag 0.5.4 → 0.5.6

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 (37) hide show
  1. package/dist-app/assets/{canonical-lpYt01q8.js → canonical-CSoZNuPg.js} +2 -2
  2. package/dist-app/assets/{canvas-record-C954zMyU.js → canvas-record-wnrN3Xiq.js} +1 -1
  3. package/dist-app/assets/claude-code-session-Cjh9ZQd-.js +8 -0
  4. package/dist-app/assets/codex-session-Dgq2Nsm-.js +12 -0
  5. package/dist-app/assets/{dsh-session-ClUmglrW.js → dsh-session-B9EsawUx.js} +3 -3
  6. package/dist-app/assets/{experiment-loop-C9mVK7yX.js → experiment-loop-CW1DebVY.js} +1 -1
  7. package/dist-app/assets/{http-bridge-BL1SuJsm.js → http-bridge-DG_nZVjI.js} +1 -1
  8. package/dist-app/assets/index-B1IC85BS.js +914 -0
  9. package/dist-app/assets/index-CgnmeNGy.css +1 -0
  10. package/dist-app/assets/{index-CHV1get9.js → index-D7EuGCbC.js} +1 -1
  11. package/dist-app/assets/{index-DRuVr_fe.js → index-Fb-Q079K.js} +1 -1
  12. package/dist-app/assets/{index-C3YBa-cP.js → index-rGP3NmkV.js} +3 -3
  13. package/dist-app/assets/local-ladder.worker-Dj3KyWai.js +1 -0
  14. package/dist-app/assets/pi-session-1yVM3dMC.js +14 -0
  15. package/dist-app/assets/{sensitive-scan-ClJE_sMR.js → sensitive-scan-CROt_JlT.js} +1 -1
  16. package/dist-app/assets/{session-handoff-BPbFjcS2.js → session-handoff-r3h38IOI.js} +2 -2
  17. package/dist-app/assets/shared-ABEucpDy.js +10 -0
  18. package/dist-app/assets/{turndown-plugin-gfm.cjs-B51wGC07.js → turndown-plugin-gfm.cjs-C6ig148J.js} +1 -1
  19. package/dist-app/assets/update-check-Bo9R7uYE.js +1 -0
  20. package/dist-app/index.html +2 -2
  21. package/lib/client.js +33 -8
  22. package/lib/index.js +183 -11
  23. package/lib/runtime/agents/README.md +1 -1
  24. package/lib/runtime/agents/claude.cjs +13 -14
  25. package/lib/runtime/agents/codex.cjs +13 -14
  26. package/lib/runtime/agents/pi.cjs +14 -27
  27. package/lib/runtime/agents/where.cjs +99 -0
  28. package/lib/why.mjs +156 -6
  29. package/package.json +1 -1
  30. package/dist-app/assets/claude-code-session-Dvg2z_fp.js +0 -7
  31. package/dist-app/assets/codex-session-C8Wr73Y-.js +0 -12
  32. package/dist-app/assets/index-C8yf-QJz.js +0 -904
  33. package/dist-app/assets/index-Lvtth6pP.css +0 -1
  34. package/dist-app/assets/local-ladder.worker-BkmzXrqq.js +0 -1
  35. package/dist-app/assets/pi-session-BNZYRZki.js +0 -14
  36. package/dist-app/assets/shared-B7orUlMM.js +0 -10
  37. package/dist-app/assets/update-check-4z5bqXf5.js +0 -1
package/lib/index.js CHANGED
@@ -293,7 +293,11 @@ function decompressZstdToText(raw) {
293
293
  * `session.jsonl.zstd`, while session format V3 (0.1.5-rc.2 and later) uses
294
294
  * `session.v3.jsonl.zstd`. Probe the known names, newest first, so a listing
295
295
  * keeps working across both. */
296
- const SESSION_LOG_NAMES = ['session.v3.jsonl.zstd', 'session.jsonl.zstd']
296
+ // `session.jsonl.zstd` (≤ 0.1.4), `session.v3.jsonl.zstd` (0.1.5), `session.v4.jsonl.zstd` (the 0.2
297
+ // desktop) …: the session directory is listed and the highest version wins, so the next format
298
+ // version is found before this file learns its name (the 0.2 desktop's own sessions were invisible
299
+ // while only v3 was probed)
300
+ const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl\.zstd?$/
297
301
  async function findSessionFiles(dshHome) {
298
302
  const root = join(dshHome, 'sessions')
299
303
  const out = []
@@ -305,13 +309,13 @@ async function findSessionFiles(dshHome) {
305
309
  try { sessions = await readdir(join(root, ws.name), { withFileTypes: true }) } catch { continue }
306
310
  for (const s of sessions) {
307
311
  if (!s.isDirectory()) continue
308
- let log
312
+ let names
313
+ try { names = await readdir(join(root, ws.name, s.name)) } catch { continue }
314
+ const best = names.map(n => ({ n, m: SESSION_LOG_RE.exec(n) })).filter(x => x.m !== null).sort((a, b) => Number(b.m[1] ?? 0) - Number(a.m[1] ?? 0))[0]
315
+ if (best === undefined) continue
316
+ const log = join(root, ws.name, s.name, best.n)
309
317
  let st
310
- for (const name of SESSION_LOG_NAMES) {
311
- const candidate = join(root, ws.name, s.name, name)
312
- try { st = await stat(candidate); log = candidate; break } catch { /* try the next format */ }
313
- }
314
- if (log === undefined || st === undefined) continue
318
+ try { st = await stat(log) } catch { continue }
315
319
  out.push({ dir: ws.name, sessionDir: s.name, log, size: st.size, mtime: st.mtimeMs })
316
320
  }
317
321
  }
@@ -534,7 +538,7 @@ async function visionIdsOf(ctx, providerId, catalogModels) {
534
538
  return out
535
539
  }
536
540
 
537
- async function modelsPayload(ctx) {
541
+ async function modelsPayload(ctx, ownProviders = []) {
538
542
  const cat = await ctx.sessionController.modelCatalog()
539
543
  // the harness itself, as an entry: the agent loop with tools, not a bare model
540
544
  // the harness's agent loop with tools, once per model it can run on —
@@ -549,15 +553,137 @@ async function modelsPayload(ctx) {
549
553
  }
550
554
  }
551
555
  models.push(...agents)
556
+ // the person's own interfaces follow, under their own names; a harness id wins a collision
557
+ const own = ownModelEntries(ownProviders, new Set(models.map(m => m.id)))
558
+ models.push(...own)
552
559
  const def = cat.default ? `${cat.default.provider}/${cat.default.model}` : null
553
560
  return {
554
561
  models,
555
562
  default: def && models.some(m => m.id === def) ? def : (models[0]?.id ?? null),
556
- capabilities: { webSearch: false, searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
563
+ capabilities: { webSearch: ownProviders.some(p => isOpenRouterURL(p.baseURL)), searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
557
564
  harness: { routableProviders: cat.routableProviders ?? [], failures: (cat.failures ?? []).map(f => ({ provider: f.id, message: f.message })) },
558
565
  }
559
566
  }
560
567
 
568
+ // ── the person's own interfaces ────────────────────────────────────────
569
+ // The canvas keeps API interfaces in the browser (the same list the desktop
570
+ // app has) and sends them along with every call and with a registration
571
+ // at boot (POST /runtime-providers). Here they are spoken to directly over
572
+ // the OpenAI-compatible protocol, so a key added inside the harness works
573
+ // the way it does in the app; the harness's own models are untouched.
574
+ const OWN_PROVIDER_CAP = 12
575
+ const OWN_MODEL_CAP = 60
576
+ const isOpenRouterURL = (u) => /openrouter\.ai/i.test(String(u ?? ''))
577
+
578
+ /** The providers a request or a registration carries, sanitised. */
579
+ function ownProvidersOf(list) {
580
+ const out = []
581
+ for (const p of (Array.isArray(list) ? list : []).slice(0, OWN_PROVIDER_CAP)) {
582
+ const baseURL = String(p?.baseURL ?? '').trim().replace(/\/+$/, '')
583
+ if (!/^https?:\/\//i.test(baseURL)) continue
584
+ const name = String(p?.name || 'Custom').slice(0, 40)
585
+ const models = (Array.isArray(p?.models) ? p.models : [])
586
+ .map(m => (typeof m === 'string' ? { id: m } : m))
587
+ .filter(m => m && typeof m.id === 'string' && m.id)
588
+ .slice(0, OWN_MODEL_CAP)
589
+ .map(m => ({ id: m.id, ...(typeof m.vision === 'boolean' ? { vision: m.vision } : {}) }))
590
+ if (models.length === 0) continue
591
+ out.push({ name, baseURL, apiKey: typeof p?.apiKey === 'string' ? p.apiKey : '', models })
592
+ }
593
+ return out
594
+ }
595
+
596
+ /** Model entries for the picker: the model's short id and its interface's name, like the app shows them. */
597
+ function ownModelEntries(providers, taken) {
598
+ const entries = []
599
+ for (const p of providers) {
600
+ for (const m of p.models) {
601
+ if (taken.has(m.id)) continue
602
+ taken.add(m.id)
603
+ const shortId = m.id.includes('/') ? m.id.split('/').slice(1).join('/') : m.id
604
+ entries.push({ id: m.id, name: `${shortId} (${p.name})`, provider: p.name, vision: m.vision })
605
+ }
606
+ }
607
+ return entries
608
+ }
609
+
610
+ /** The interface a requested model belongs to, from the request's own list first, then the registered list. */
611
+ function ownTargetOf(requested, fromBody, registered) {
612
+ if (typeof requested !== 'string' || !requested) return null
613
+ for (const p of [...fromBody, ...registered]) if (p.models.some(m => m.id === requested)) return { provider: p, model: requested }
614
+ return null
615
+ }
616
+
617
+ /** The canvas's { role, content } messages and images as OpenAI-compatible messages. */
618
+ function ownMessagesOf(body) {
619
+ const messages = []
620
+ for (const m of Array.isArray(body?.messages) ? body.messages : []) {
621
+ if (!m || typeof m.content !== 'string') continue
622
+ messages.push({ role: m.role === 'system' ? 'system' : m.role === 'assistant' ? 'assistant' : 'user', content: m.content })
623
+ }
624
+ if (messages.length === 0) throw new HttpError(400, 'messages: nothing to send')
625
+ const images = Array.isArray(body?.images) ? body.images.filter(i => i && typeof i.data === 'string' && IMAGE_MEDIA.has(i.mimeType)) : []
626
+ if (images.length > 0) {
627
+ for (let i = messages.length - 1; i >= 0; i--) {
628
+ if (messages[i].role !== 'user') continue
629
+ messages[i] = { role: 'user', content: [{ type: 'text', text: messages[i].content }, ...images.map(img => ({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${img.data}` } }))] }
630
+ break
631
+ }
632
+ }
633
+ return messages
634
+ }
635
+
636
+ /** One chat-completions request to the interface; the response as fetched (the caller reads it). */
637
+ async function ownRequest(target, body, stream) {
638
+ const { provider, model } = target
639
+ // OpenRouter searches through its `:online` variant and takes a reasoning switch; a fast call never thinks out loud
640
+ const openrouter = isOpenRouterURL(provider.baseURL)
641
+ const id = openrouter && body?.webSearch && !model.endsWith(':online') ? `${model}:online` : model
642
+ const payload = { model: id, messages: ownMessagesOf(body), stream, ...(openrouter ? { reasoning: { enabled: !body?.fast } } : {}) }
643
+ const headers = { 'content-type': 'application/json', ...(provider.apiKey ? { authorization: `Bearer ${provider.apiKey}` } : {}), ...(openrouter ? { 'HTTP-Referer': 'https://github.com/chenxiachan/thoughtdag', 'X-Title': 'ThoughtDAG' } : {}) }
644
+ const res = await fetch(`${provider.baseURL}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(10 * 60 * 1000) })
645
+ if (!res.ok) {
646
+ const text = await res.text().catch(() => '')
647
+ let detail = text.slice(0, 300)
648
+ try { const j = JSON.parse(text); detail = j?.error?.message ?? j?.message ?? detail } catch { /* not json */ }
649
+ throw new HttpError(res.status === 401 || res.status === 403 ? 401 : 502, `${provider.name}: HTTP ${res.status}${detail ? ' · ' + detail : ''}`)
650
+ }
651
+ return res
652
+ }
653
+
654
+ /** Stream one answer from the interface as the canvas's frames: { text }, { reasoning }, [DONE]. */
655
+ async function ownStream(res, target, body, isClosed) {
656
+ const upstream = await ownRequest(target, body, true)
657
+ const decoder = new TextDecoder()
658
+ let buffer = ''
659
+ for await (const chunk of upstream.body) {
660
+ if (isClosed()) break
661
+ buffer += decoder.decode(chunk, { stream: true })
662
+ let nl
663
+ while ((nl = buffer.indexOf('\n')) >= 0) {
664
+ const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1)
665
+ if (!line.startsWith('data:')) continue
666
+ const data = line.slice(5).trim()
667
+ if (data === '[DONE]') return
668
+ let parsed
669
+ try { parsed = JSON.parse(data) } catch { continue }
670
+ if (parsed?.error) { res.write(`data: ${JSON.stringify({ error: parsed.error.message ?? String(parsed.error) })}\n\n`); return }
671
+ const delta = parsed?.choices?.[0]?.delta ?? {}
672
+ const reasoning = delta.reasoning_content ?? delta.reasoning
673
+ if (typeof reasoning === 'string' && reasoning) res.write(`data: ${JSON.stringify({ reasoning })}\n\n`)
674
+ if (typeof delta.content === 'string' && delta.content) res.write(`data: ${JSON.stringify({ text: delta.content })}\n\n`)
675
+ }
676
+ }
677
+ }
678
+
679
+ /** One whole answer from the interface (the canvas's background calls). */
680
+ async function ownCall(target, body) {
681
+ const upstream = await ownRequest(target, body, false)
682
+ const j = await upstream.json()
683
+ const c = j?.choices?.[0]?.message?.content
684
+ return typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => (typeof x?.text === 'string' ? x.text : '')).join('') : ''
685
+ }
686
+
561
687
  const IMAGE_MEDIA = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
562
688
 
563
689
  /** The SPA's { role, content } messages as harness Messages; system lines
@@ -834,7 +960,7 @@ async function visionModel(ctx) {
834
960
 
835
961
  /** Resolve the requested model against the catalog, falling back to its default. */
836
962
  async function targetOf(ctx, requested) {
837
- const payload = await modelsPayload(ctx)
963
+ const payload = await modelsPayload(ctx) // the harness's own; the person's interfaces are resolved before this is asked
838
964
  const id = requested && payload.models.some(m => m.id === requested) ? requested : payload.default
839
965
  const t = splitModelId(id)
840
966
  if (!t) throw new HttpError(503, 'no model is routable in this harness')
@@ -943,6 +1069,8 @@ export async function apply(ctx, config) {
943
1069
  : '/thoughtdag'
944
1070
  const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(h => String(h).trim().toLowerCase()).filter(Boolean)])
945
1071
 
1072
+ // the interfaces the canvas registered (memory only: a restart forgets them, the canvas registers again at boot)
1073
+ let registeredProviders = []
946
1074
  const api = async (req, res) => {
947
1075
  try {
948
1076
  const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
@@ -1010,6 +1138,7 @@ export async function apply(ctx, config) {
1010
1138
  const cwd = url.searchParams.get('cwd') ?? undefined
1011
1139
  return sendJson(res, 200, await whyLib.findJson(phrase, { scope, limit, ...(cwd ? { cwd } : {}) }))
1012
1140
  }
1141
+ if (path === '/why/turns') return sendJson(res, 200, await whyLib.turnsJson({ offset: Number(url.searchParams.get('offset') ?? 0) || 0, ...(url.searchParams.get('limit') === null ? {} : { limit: Number(url.searchParams.get('limit')) || 0 }), head: Number(url.searchParams.get('head') ?? 300) || 300 }))
1013
1142
  if (path === '/why/recall') {
1014
1143
  try { return sendJson(res, 200, await whyLib.recallJson(url.searchParams.get('session') ?? '', Number(url.searchParams.get('turn') ?? 0))) }
1015
1144
  catch (e) { return sendJson(res, 404, { error: e instanceof Error ? e.message : String(e) }) }
@@ -1089,7 +1218,34 @@ export async function apply(ctx, config) {
1089
1218
  if (await agentsHttp().handle(req, res, path, body)) return
1090
1219
  }
1091
1220
  // ── model connection (the SPA's proxy protocol, on the harness's providers) ──
1092
- if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx))
1221
+ if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx, registeredProviders))
1222
+ // the canvas registers its browser-stored interfaces at boot (and after a change): the list answers with them in it
1223
+ // the interface dialog asks an endpoint what it serves (the /models protocol standard), through this host
1224
+ if (path === '/probe-models' && req.method === 'POST') {
1225
+ const body = await readJson(req, MAX_WRITE_BODY_BYTES)
1226
+ const baseURL = String(body?.baseURL ?? '').trim().replace(/\/+$/, '')
1227
+ if (!/^https?:\/\//i.test(baseURL)) return sendJson(res, 400, { error: 'baseURL required' })
1228
+ try {
1229
+ const r = await fetch(`${baseURL}/models`, { headers: body?.apiKey ? { authorization: `Bearer ${body.apiKey}` } : {}, signal: AbortSignal.timeout(15000) })
1230
+ if (!r.ok) return sendJson(res, r.status, { error: `endpoint answered HTTP ${r.status}` })
1231
+ const j = await r.json()
1232
+ const list = Array.isArray(j?.data) ? j.data : Array.isArray(j?.models) ? j.models : []
1233
+ const models = list.map(m => ({
1234
+ id: typeof (m?.id ?? m?.name) === 'string' ? (m.id ?? m.name).replace(/^models\//, '') : '',
1235
+ ...(typeof m?.created === 'number' ? { created: m.created } : {}),
1236
+ ...(Array.isArray(m?.architecture?.input_modalities) ? { vision: m.architecture.input_modalities.includes('image') } : {}),
1237
+ ...(typeof m?.context_length === 'number' ? { contextLength: m.context_length } : {}),
1238
+ })).filter(m => m.id)
1239
+ return sendJson(res, 200, { models })
1240
+ } catch (error) {
1241
+ return sendJson(res, 502, { error: 'could not reach the endpoint: ' + (error instanceof Error ? error.message : String(error)) })
1242
+ }
1243
+ }
1244
+ if (path === '/runtime-providers' && req.method === 'POST') {
1245
+ const body = await readJson(req, MAX_CALL_BODY_BYTES)
1246
+ registeredProviders = ownProvidersOf(body?.providers)
1247
+ return sendJson(res, 200, await modelsPayload(ctx, registeredProviders))
1248
+ }
1093
1249
  const approvalRoute = /^\/approvals\/([^/]+)$/.exec(path)
1094
1250
  if (approvalRoute !== null && req.method === 'POST') {
1095
1251
  const id = decodeURIComponent(approvalRoute[1])
@@ -1119,6 +1275,22 @@ export async function apply(ctx, config) {
1119
1275
  res.end()
1120
1276
  return
1121
1277
  }
1278
+ // the person's own interface, when the model is one of theirs
1279
+ const own = ownTargetOf(body?.model, ownProvidersOf(body?.providers), registeredProviders)
1280
+ if (own) {
1281
+ if (path === '/claude') return sendJson(res, 200, { text: await ownCall(own, body), model: own.model })
1282
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
1283
+ let closed = false
1284
+ req.on('close', () => { closed = true })
1285
+ try {
1286
+ await ownStream(res, own, body, () => closed)
1287
+ if (!closed) res.write('data: [DONE]\n\n')
1288
+ } catch (error) {
1289
+ if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
1290
+ }
1291
+ res.end()
1292
+ return
1293
+ }
1122
1294
  const target = await targetOf(ctx, body?.model)
1123
1295
  const options = await callOf(ctx, body, target)
1124
1296
  if (path === '/claude') {
@@ -3,7 +3,7 @@
3
3
  One file per runtime (CommonJS, `.cjs`, so it loads the same under any package type): `pi.cjs` over `pi --mode rpc`, `codex.cjs` over `codex app-server`, `claude.cjs` over `claude -p` with stream-json both ways. Each exports a factory returning the same surface:
4
4
 
5
5
  - `available()` → where the binary is, or null
6
- - `models()` → `{ installed, models: [{ provider, id, name, reasoning, vision, efforts, defaultEffort }], default }` — `efforts` are the effort levels the CLI accepts for that model, in its own words, read from the CLI (empty when it has none); `defaultEffort` its own default when it says
6
+ - `models()` → `{ installed, models: [{ provider, id, name, reasoning, vision, efforts, defaultEffort }], default }`; when the CLI is nowhere, `{ installed: false, searched: [dirs] }` names every directory the lookup walked (see `where.cjs`: the process PATH, then the login shell's PATH, then the usual homes and Node version managers), and the next ask looks again — `efforts` are the effort levels the CLI accepts for that model, in its own words, read from the CLI (empty when it has none); `defaultEffort` its own default when it says
7
7
  - `run(request, onEvent)` → run id at once; events follow, tagged with it. The request names the runtime, an absolute `cwd`, one `prompt`, optional `images`, a `model` from that runtime's own list, a session to continue (`sessionPath`, plus `forkEntryId` to branch it), and optionally `effort`: one of the levels the runtime itself listed for that model, in its own words (Codex from `model/list`, Claude Code and Pi from their `--help`), which it passes on as is (`--effort`, the turn's `effort`, `set_thinking_level`). A word the runtime did not list is dropped: the CLI's own setting stands.
8
8
  - `abort(runId)`, `answer(runId, requestId, { confirmed } | { value } | { cancelled: true })`, `shutdown()`
9
9
 
@@ -16,19 +16,13 @@ const { randomUUID } = require('node:crypto');
16
16
  const { snapshotDir, diffSnapshots } = require('./fs-diff.cjs');
17
17
 
18
18
  const fsp = fs.promises;
19
- const CANDIDATE_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.claude', 'local'), path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.bun', 'bin')];
19
+ const { locate, childEnv: envWith } = require('./where.cjs');
20
+ const EXTRA_HOMES = [path.join(os.homedir(), '.claude', 'local')]; // the CLI's own install location, on top of the usual ones
20
21
 
21
- async function executable(p) { try { await fsp.access(p, fs.constants.X_OK); return true; } catch { return false; } }
22
- async function findClaude() {
23
- const names = process.platform === 'win32' ? ['claude.cmd', 'claude.exe', 'claude'] : ['claude'];
24
- const dirs = [...(process.env.PATH || '').split(path.delimiter), ...CANDIDATE_DIRS].filter(Boolean);
25
- for (const d of dirs) for (const n of names) { const p = path.join(d, n); if (await executable(p)) return p; }
26
- return null;
27
- }
28
- function childEnv() {
29
- const seen = new Set((process.env.PATH || '').split(path.delimiter).filter(Boolean));
30
- return { ...process.env, PATH: [...seen, ...CANDIDATE_DIRS.filter((d) => !seen.has(d))].join(path.delimiter) };
31
- }
22
+ /** The claude binary and the directories walked to find it (see where.cjs). */
23
+ let located = null;
24
+ const findClaude = () => (located ??= locate('claude', EXTRA_HOMES));
25
+ const childEnv = () => envWith(EXTRA_HOMES);
32
26
 
33
27
  /** Claude Code has no model list; its aliases resolve to the newest model
34
28
  * of each family inside the CLI, so they never go stale. The CLI's own
@@ -114,14 +108,19 @@ function createClaudeRuntime({ log } = {}) {
114
108
  const say = log || (() => {});
115
109
  const runs = new Map(); // runId → state
116
110
  let binPromise = null;
117
- const bin = () => { if (!binPromise) binPromise = findClaude(); return binPromise; };
111
+ const bin = () => {
112
+ if (!binPromise) binPromise = findClaude().then((r) => { if (!r.path) { located = null; binPromise = null; } return r.path; });
113
+ return binPromise;
114
+ };
115
+ /** Where the lookup walked, for the picker's "not found" note. */
116
+ const searched = async () => (await findClaude()).searched;
118
117
 
119
118
  return {
120
119
  available: () => bin(),
121
120
 
122
121
  async models() {
123
122
  const b = await bin();
124
- if (!b) return { installed: false, models: [], default: null };
123
+ if (!b) return { installed: false, models: [], default: null, searched: await searched() };
125
124
  const [efforts, defaultEffort] = await Promise.all([effortsOf(b), configuredEffort()]);
126
125
  return { installed: true, models: MODELS.map((m) => ({ ...m, efforts, defaultEffort })), default: 'claude/claude-code/opus' };
127
126
  },
@@ -17,19 +17,13 @@ const { snapshotDir, diffSnapshots } = require('./fs-diff.cjs');
17
17
 
18
18
  const fsp = fs.promises;
19
19
  const RESPONSE_MS = 30 * 1000;
20
- const CANDIDATE_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', path.join(os.homedir(), '.bun', 'bin'), path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.local', 'bin')];
20
+ const { locate, childEnv: envWith } = require('./where.cjs');
21
+ const EXTRA_HOMES = [path.join(os.homedir(), '.codex', 'bin')];
21
22
 
22
- async function executable(p) { try { await fsp.access(p, fs.constants.X_OK); return true; } catch { return false; } }
23
- async function findCodex() {
24
- const names = process.platform === 'win32' ? ['codex.cmd', 'codex.exe', 'codex'] : ['codex'];
25
- const dirs = [...(process.env.PATH || '').split(path.delimiter), ...CANDIDATE_DIRS].filter(Boolean);
26
- for (const d of dirs) for (const n of names) { const p = path.join(d, n); if (await executable(p)) return p; }
27
- return null;
28
- }
29
- function childEnv() {
30
- const seen = new Set((process.env.PATH || '').split(path.delimiter).filter(Boolean));
31
- return { ...process.env, PATH: [...seen, ...CANDIDATE_DIRS.filter((d) => !seen.has(d))].join(path.delimiter) };
32
- }
23
+ /** The codex binary and the directories walked to find it (see where.cjs). */
24
+ let located = null;
25
+ const findCodex = () => (located ??= locate('codex', EXTRA_HOMES));
26
+ const childEnv = () => envWith(EXTRA_HOMES);
33
27
 
34
28
  /** The guard file the canvas writes per working directory, as Codex policy. */
35
29
  async function policyFor(cwd) {
@@ -58,7 +52,12 @@ function createCodexRuntime({ log } = {}) {
58
52
  const byThread = new Map(); // threadId → runId
59
53
  const serverAsks = new Map(); // question id → { rpcId, respond(answer) }
60
54
  let binPromise = null;
61
- const bin = () => { if (!binPromise) binPromise = findCodex(); return binPromise; };
55
+ const bin = () => {
56
+ if (!binPromise) binPromise = findCodex().then((r) => { if (!r.path) { located = null; binPromise = null; } return r.path; });
57
+ return binPromise;
58
+ };
59
+ /** Where the lookup walked, for the picker's "not found" note. */
60
+ const searched = async () => (await findCodex()).searched;
62
61
 
63
62
  const write = (obj) => { try { proc.stdin.write(JSON.stringify(obj) + '\n'); return true; } catch { return false; } };
64
63
  const send = (method, params, timeoutMs = RESPONSE_MS) => new Promise((resolve, reject) => {
@@ -283,7 +282,7 @@ function createCodexRuntime({ log } = {}) {
283
282
 
284
283
  async models() {
285
284
  const b = await bin();
286
- if (!b) return { installed: false, models: [], default: null };
285
+ if (!b) return { installed: false, models: [], default: null, searched: await searched() };
287
286
  await ensureServer();
288
287
  const r = await send('model/list', {});
289
288
  noteEfforts(r);
@@ -23,32 +23,14 @@ const fsp = fs.promises;
23
23
  const GUARD = path.join(__dirname, 'pi-guard.mjs');
24
24
  const IDLE_MS = Number(process.env.TD_AGENT_IDLE_MS) || 10 * 60 * 1000; // a Pi process (session or catalog) retires after this long without a listener
25
25
  const RESPONSE_MS = 30 * 1000;
26
- const CANDIDATE_DIRS = [
27
- '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin',
28
- path.join(os.homedir(), '.bun', 'bin'), path.join(os.homedir(), '.npm-global', 'bin'),
29
- path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.pi', 'bin'),
30
- ];
26
+ const { locate, childEnv: envWith } = require('./where.cjs');
27
+ const EXTRA_HOMES = [path.join(os.homedir(), '.pi', 'bin')]; // pi's own home, on top of the usual ones (see where.cjs)
31
28
 
32
- async function executable(p) {
33
- try { await fsp.access(p, fs.constants.X_OK); return true; } catch { return false; }
34
- }
35
-
36
- /** The pi binary: PATH first (a terminal launch), then the usual homes
37
- * (a Finder launch carries almost no PATH). */
38
- async function findPi() {
39
- const names = process.platform === 'win32' ? ['pi.cmd', 'pi.exe', 'pi'] : ['pi'];
40
- const dirs = [...(process.env.PATH || '').split(path.delimiter), ...CANDIDATE_DIRS].filter(Boolean);
41
- for (const d of dirs) for (const n of names) { const p = path.join(d, n); if (await executable(p)) return p; }
42
- return null;
43
- }
44
-
45
- /** PATH for the child: the shell's plus the usual homes, so Pi's own bash
46
- * tool finds git, node and friends even under a Finder launch. */
47
- function childEnv() {
48
- const seen = new Set((process.env.PATH || '').split(path.delimiter).filter(Boolean));
49
- const extra = CANDIDATE_DIRS.filter((d) => !seen.has(d));
50
- return { ...process.env, PATH: [...seen, ...extra].join(path.delimiter) };
51
- }
29
+ /** The pi binary and the directories walked to find it (PATH, the login shell's PATH, the usual homes). */
30
+ let located = null;
31
+ const findPi = () => (located ??= locate('pi', EXTRA_HOMES));
32
+ /** PATH for the child: the shell's plus the usual homes, so Pi's own bash tool finds git, node and friends even under a Finder launch. */
33
+ const childEnv = () => envWith(EXTRA_HOMES);
52
34
 
53
35
  class PiProcess {
54
36
  constructor(bin, cwd, opts) {
@@ -191,7 +173,12 @@ function createPiRuntime({ log } = {}) {
191
173
  const runs = new Map(); // runId → { proc, abort() }
192
174
  let binPromise = null;
193
175
 
194
- const bin = () => { if (!binPromise) binPromise = findPi(); return binPromise; };
176
+ const bin = () => {
177
+ if (!binPromise) binPromise = findPi().then((r) => { if (!r.path) { located = null; binPromise = null; } return r.path; });
178
+ return binPromise;
179
+ };
180
+ /** Where the lookup walked, for the picker's "not found" note. */
181
+ const searched = async () => (await findPi()).searched;
195
182
  // Pi's thinking levels in its own words, from its own `--help`
196
183
  // (`--thinking <level> Set thinking level: off, minimal, …`); read once
197
184
  let levelsPromise = null;
@@ -228,7 +215,7 @@ function createPiRuntime({ log } = {}) {
228
215
  * sessionless process in the home directory. */
229
216
  async models() {
230
217
  const b = await bin();
231
- if (!b) return { installed: false, models: [], default: null };
218
+ if (!b) return { installed: false, models: [], default: null, searched: await searched() };
232
219
  const key = '\0catalog';
233
220
  let p = procs.get(key);
234
221
  if (!p || p.dead) { p = new PiProcess(b, os.homedir(), { log: say, noSession: true, onExit: (d) => { if (procs.get(key) === d) procs.delete(key); } }); procs.set(key, p); }
@@ -0,0 +1,99 @@
1
+ // Where a CLI lives when the process that launched us had no PATH to speak of.
2
+ //
3
+ // An app opened from the Finder, the Dock or a Start menu inherits a bare PATH
4
+ // (/usr/bin:/bin on macOS), while the person's CLIs sit wherever their Node
5
+ // version manager put them (nvm, fnm, volta, pnpm, asdf, mise …). A lookup
6
+ // therefore walks three lists in order: the process PATH, the PATH a login
7
+ // shell would give a terminal (asked once, with a timeout), and the usual
8
+ // homes, version managers included. Every lookup returns what it searched,
9
+ // so the picker can say where it looked when a runtime is missing.
10
+ 'use strict';
11
+
12
+ const { execFile } = require('node:child_process');
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+
17
+ const fsp = fs.promises;
18
+ const home = os.homedir();
19
+ const isWin = process.platform === 'win32';
20
+ const LOGIN_SHELL_MS = 4000;
21
+
22
+ /** The login shell's PATH (zsh, bash and fish read their rc files there). Asked once; empty on Windows or when the shell is silent. */
23
+ let loginPathPromise = null;
24
+ function loginPath() {
25
+ if (loginPathPromise) return loginPathPromise;
26
+ loginPathPromise = new Promise((resolve) => {
27
+ if (isWin) return resolve([]);
28
+ const shell = process.env.SHELL || '/bin/zsh';
29
+ const args = /fish$/.test(shell) ? ['-l', '-c', 'string join : $PATH'] : ['-ilc', 'printf "%s" "$PATH"'];
30
+ execFile(shell, args, { timeout: LOGIN_SHELL_MS, env: { ...process.env, TERM: 'dumb' }, maxBuffer: 1 << 16 }, (err, stdout) => {
31
+ if (err) return resolve([]);
32
+ // rc files may print greetings; PATH itself has no newline, so the last line is ours
33
+ const line = String(stdout || '').split('\n').map((s) => s.trim()).filter(Boolean).pop() || '';
34
+ resolve(line.split(path.delimiter).map((d) => d.trim()).filter(Boolean));
35
+ });
36
+ });
37
+ return loginPathPromise;
38
+ }
39
+
40
+ const numeric = (v) => String(v).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
41
+ const newestFirst = (a, b) => { const x = numeric(a), y = numeric(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return (y[i] || 0) - (x[i] || 0); return 0; };
42
+ const versionsUnder = (dir, ...sub) => { try { return fs.readdirSync(dir).sort(newestFirst).map((v) => path.join(dir, v, ...sub)); } catch { return []; } };
43
+
44
+ /** Where package managers and Node version managers put global binaries. Newest Node first, so a lookup lands on the version the person uses. */
45
+ function managerDirs() {
46
+ const dirs = [];
47
+ dirs.push(...versionsUnder(path.join(process.env.NVM_DIR || path.join(home, '.nvm'), 'versions', 'node'), 'bin'));
48
+ for (const base of [process.env.FNM_DIR, path.join(home, '.fnm'), path.join(home, '.local', 'share', 'fnm'), path.join(home, 'Library', 'Application Support', 'fnm')].filter(Boolean)) {
49
+ dirs.push(path.join(base, 'aliases', 'default', 'bin'));
50
+ dirs.push(...versionsUnder(path.join(base, 'node-versions'), 'installation', 'bin'));
51
+ }
52
+ dirs.push(
53
+ path.join(home, '.volta', 'bin'), path.join(process.env.N_PREFIX || path.join(home, 'n'), 'bin'), path.join(home, '.nodenv', 'shims'),
54
+ path.join(home, '.asdf', 'shims'), path.join(home, '.local', 'share', 'mise', 'shims'), path.join(home, '.proto', 'shims'),
55
+ path.join(home, 'Library', 'pnpm'), path.join(home, '.local', 'share', 'pnpm'), path.join(home, '.yarn', 'bin'), path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'),
56
+ path.join(home, '.deno', 'bin'), path.join(home, '.cargo', 'bin'), path.join(home, 'bin'),
57
+ );
58
+ if (isWin) {
59
+ const { APPDATA, LOCALAPPDATA, ProgramFiles } = process.env;
60
+ if (APPDATA) dirs.push(path.join(APPDATA, 'npm'));
61
+ if (LOCALAPPDATA) dirs.push(path.join(LOCALAPPDATA, 'pnpm'), path.join(LOCALAPPDATA, 'Volta', 'bin'), path.join(LOCALAPPDATA, 'Programs', 'nvm'));
62
+ if (ProgramFiles) dirs.push(path.join(ProgramFiles, 'nodejs'));
63
+ }
64
+ return dirs;
65
+ }
66
+
67
+ const COMMON_HOMES = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', path.join(home, '.bun', 'bin'), path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin')];
68
+
69
+ /** Every directory a lookup walks, in order and without repeats. Sync when the login shell has not answered yet. */
70
+ function dirsSync(extraHomes, login) {
71
+ const seen = new Set(); const out = [];
72
+ const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
73
+ (process.env.PATH || '').split(path.delimiter).forEach(add);
74
+ login.forEach(add);
75
+ [...COMMON_HOMES, ...extraHomes, ...managerDirs()].forEach(add);
76
+ return out;
77
+ }
78
+ let loginSeen = [];
79
+ async function searchDirs(extraHomes = []) {
80
+ loginSeen = await loginPath();
81
+ return dirsSync(extraHomes, loginSeen);
82
+ }
83
+
84
+ async function executable(p) { try { await fsp.access(p, fs.constants.X_OK); return true; } catch { return false; } }
85
+
86
+ /** The first executable called `name` (Windows tries .cmd and .exe too): `{ path, searched }`, path null when nowhere. */
87
+ async function locate(name, extraHomes = []) {
88
+ const names = isWin ? [`${name}.cmd`, `${name}.exe`, name] : [name];
89
+ const searched = await searchDirs(extraHomes);
90
+ for (const d of searched) for (const n of names) { const p = path.join(d, n); if (await executable(p)) return { path: p, searched }; }
91
+ return { path: null, searched };
92
+ }
93
+
94
+ /** The process environment with PATH widened the same way, so a CLI's own tools (git, node, bash) resolve under a Finder launch too. */
95
+ function childEnv(extraHomes = []) {
96
+ return { ...process.env, PATH: dirsSync(extraHomes, loginSeen).join(path.delimiter) };
97
+ }
98
+
99
+ module.exports = { locate, childEnv, searchDirs, loginPath };