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.
- package/dist-app/assets/{canonical-lpYt01q8.js → canonical-CSoZNuPg.js} +2 -2
- package/dist-app/assets/{canvas-record-C954zMyU.js → canvas-record-wnrN3Xiq.js} +1 -1
- package/dist-app/assets/claude-code-session-Cjh9ZQd-.js +8 -0
- package/dist-app/assets/codex-session-Dgq2Nsm-.js +12 -0
- package/dist-app/assets/{dsh-session-ClUmglrW.js → dsh-session-B9EsawUx.js} +3 -3
- package/dist-app/assets/{experiment-loop-C9mVK7yX.js → experiment-loop-CW1DebVY.js} +1 -1
- package/dist-app/assets/{http-bridge-BL1SuJsm.js → http-bridge-DG_nZVjI.js} +1 -1
- package/dist-app/assets/index-B1IC85BS.js +914 -0
- package/dist-app/assets/index-CgnmeNGy.css +1 -0
- package/dist-app/assets/{index-CHV1get9.js → index-D7EuGCbC.js} +1 -1
- package/dist-app/assets/{index-DRuVr_fe.js → index-Fb-Q079K.js} +1 -1
- package/dist-app/assets/{index-C3YBa-cP.js → index-rGP3NmkV.js} +3 -3
- package/dist-app/assets/local-ladder.worker-Dj3KyWai.js +1 -0
- package/dist-app/assets/pi-session-1yVM3dMC.js +14 -0
- package/dist-app/assets/{sensitive-scan-ClJE_sMR.js → sensitive-scan-CROt_JlT.js} +1 -1
- package/dist-app/assets/{session-handoff-BPbFjcS2.js → session-handoff-r3h38IOI.js} +2 -2
- package/dist-app/assets/shared-ABEucpDy.js +10 -0
- package/dist-app/assets/{turndown-plugin-gfm.cjs-B51wGC07.js → turndown-plugin-gfm.cjs-C6ig148J.js} +1 -1
- package/dist-app/assets/update-check-Bo9R7uYE.js +1 -0
- package/dist-app/index.html +2 -2
- package/lib/client.js +33 -8
- package/lib/index.js +183 -11
- package/lib/runtime/agents/README.md +1 -1
- package/lib/runtime/agents/claude.cjs +13 -14
- package/lib/runtime/agents/codex.cjs +13 -14
- package/lib/runtime/agents/pi.cjs +14 -27
- package/lib/runtime/agents/where.cjs +99 -0
- package/lib/why.mjs +156 -6
- package/package.json +1 -1
- package/dist-app/assets/claude-code-session-Dvg2z_fp.js +0 -7
- package/dist-app/assets/codex-session-C8Wr73Y-.js +0 -12
- package/dist-app/assets/index-C8yf-QJz.js +0 -904
- package/dist-app/assets/index-Lvtth6pP.css +0 -1
- package/dist-app/assets/local-ladder.worker-BkmzXrqq.js +0 -1
- package/dist-app/assets/pi-session-BNZYRZki.js +0 -14
- package/dist-app/assets/shared-B7orUlMM.js +0 -10
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 = () => {
|
|
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
|
|
20
|
+
const { locate, childEnv: envWith } = require('./where.cjs');
|
|
21
|
+
const EXTRA_HOMES = [path.join(os.homedir(), '.codex', 'bin')];
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 = () => {
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 = () => {
|
|
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 };
|