dsh-thoughtdag 0.4.8 → 0.4.10
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-HRd1ZNsL.js → canonical-BZIQSZy0.js} +2 -2
- package/dist-app/assets/{canvas-record-sMlQAe86.js → canvas-record-D7Ns1Kpg.js} +1 -1
- package/dist-app/assets/{claude-code-session-CNy9Vlyr.js → claude-code-session-0K23DXD3.js} +1 -1
- package/dist-app/assets/{codex-session-IGOBB6wr.js → codex-session-Cs0gAA_-.js} +1 -1
- package/dist-app/assets/{dsh-session-C33CwagF.js → dsh-session-Bl9dP5PX.js} +1 -1
- package/dist-app/assets/{experiment-loop-LWCgLcKh.js → experiment-loop-GwbyJMFI.js} +1 -1
- package/dist-app/assets/http-bridge-mmwHcDuo.js +1 -0
- package/dist-app/assets/{index-BlhS4j5g.js → index-B5j_QAmX.js} +1 -1
- package/dist-app/assets/{index-CxfScRHS.js → index-BBqrNNdn.js} +182 -158
- package/dist-app/assets/{index-D1pfymrb.css → index-BfIl0Oie.css} +1 -1
- package/dist-app/assets/{index-CrxLUdb9.js → index-CZpsihFG.js} +3 -3
- package/dist-app/assets/{index-D_9Fas4A.js → index-DWm1onYm.js} +1 -1
- package/dist-app/assets/{live-mirror-DrVBjNxd.js → live-mirror-DXHCi_Hf.js} +2 -2
- package/dist-app/assets/{pi-session-64OkE2In.js → pi-session-D-6Qneax.js} +1 -1
- package/dist-app/assets/{sensitive-scan-BjlPCfxy.js → sensitive-scan-BiyGrjXf.js} +1 -1
- package/dist-app/assets/{session-handoff-TXfFS7pB.js → session-handoff-IiyUUNcD.js} +2 -2
- package/dist-app/assets/{shared-Bad_vakP.js → shared-BUQtzATG.js} +1 -1
- package/dist-app/assets/{turndown-plugin-gfm.cjs-DXKuEPQs.js → turndown-plugin-gfm.cjs-BOHTxH4C.js} +1 -1
- package/dist-app/assets/{update-check-BCL5Q3Vc.js → update-check-BojzO74W.js} +1 -1
- package/dist-app/index.html +2 -2
- package/lib/client.js +5 -1
- package/lib/index.js +60 -4
- package/lib/runtime/agents/README.md +24 -0
- package/lib/runtime/agents/codex.cjs +302 -0
- package/lib/runtime/agents/fs-diff.cjs +44 -0
- package/lib/runtime/agents/http.cjs +94 -0
- package/lib/runtime/agents/ops.cjs +54 -0
- package/lib/runtime/agents/pi-guard.mjs +81 -0
- package/lib/runtime/agents/pi.cjs +343 -0
- package/lib/runtime/sessions-fs.cjs +100 -0
- package/lib/runtime/terminal.cjs +105 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
import { randomUUID } from 'node:crypto'
|
|
71
71
|
import { open, readFile, readdir, stat } from 'node:fs/promises'
|
|
72
72
|
import { extname, join, normalize, resolve, sep } from 'node:path'
|
|
73
|
+
import { createRequire } from 'node:module'
|
|
73
74
|
import os from 'node:os'
|
|
74
75
|
import { fileURLToPath } from 'node:url'
|
|
75
76
|
import { zstdDecompressSync } from 'node:zlib'
|
|
@@ -78,8 +79,34 @@ export const name = 'thoughtdag'
|
|
|
78
79
|
export const inject = ['webServer', 'sessions', 'sessionController', 'agents', 'llm', 'attachments', 'web', 'tools', 'commands', 'systemPrompt', 'approval']
|
|
79
80
|
|
|
80
81
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
82
|
+
const require = createRequire(import.meta.url)
|
|
81
83
|
const APP_DIR = resolve(__dirname, '../dist-app')
|
|
82
84
|
|
|
85
|
+
// The plugin's own version travels into the canvas's URL (?dv=), so the
|
|
86
|
+
// update dialog and the release history know which release runs here, as
|
|
87
|
+
// they do in the desktop shell. The registry is asked for the newest
|
|
88
|
+
// version at most once a day; a failed lookup is silence, not an error.
|
|
89
|
+
const PLUGIN_VERSION = (() => { try { return require(resolve(__dirname, '..', 'package.json')).version ?? null } catch { return null } })()
|
|
90
|
+
let latestLookup = { value: null, at: 0 }
|
|
91
|
+
async function latestPluginVersion() {
|
|
92
|
+
if (Date.now() - latestLookup.at < 24 * 60 * 60 * 1000) return latestLookup.value
|
|
93
|
+
latestLookup = { value: latestLookup.value, at: Date.now() }
|
|
94
|
+
try {
|
|
95
|
+
const r = await fetch('https://registry.npmjs.org/dsh-thoughtdag/latest', { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } })
|
|
96
|
+
if (r.ok) { const j = await r.json(); if (typeof j?.version === 'string') latestLookup.value = j.version }
|
|
97
|
+
} catch { /* offline, or the registry is slow: keep what we had */ }
|
|
98
|
+
return latestLookup.value
|
|
99
|
+
}
|
|
100
|
+
// the shared runtime, copied under lib/runtime by the build (Node code, no harness dependency)
|
|
101
|
+
let agentsHttpInstance = null
|
|
102
|
+
function agentsHttp() {
|
|
103
|
+
if (!agentsHttpInstance) {
|
|
104
|
+
const { createAgentsHttp } = require(resolve(__dirname, 'runtime', 'agents', 'http.cjs'))
|
|
105
|
+
agentsHttpInstance = createAgentsHttp({ log: (line) => console.error(line) })
|
|
106
|
+
}
|
|
107
|
+
return agentsHttpInstance
|
|
108
|
+
}
|
|
109
|
+
|
|
83
110
|
const MAX_BODY_BYTES = 32 * 1024
|
|
84
111
|
// a compiled canvas context can be long; the write endpoints take up to this
|
|
85
112
|
const MAX_WRITE_BODY_BYTES = 4 * 1024 * 1024
|
|
@@ -449,6 +476,18 @@ function splitModelId(id) {
|
|
|
449
476
|
* seeing images: a model is marked vision when its adapter declares image
|
|
450
477
|
* input; those images then enter the attachment store and ride the call. */
|
|
451
478
|
const AGENT_MODEL = 'harness/agent'
|
|
479
|
+
/** One agent entry per catalog model: 'harness/agent/<provider>/<model>'.
|
|
480
|
+
* The bare 'harness/agent' stays accepted (older canvases) and means the
|
|
481
|
+
* session's current model. */
|
|
482
|
+
const isAgentModelId = (id) => typeof id === 'string' && (id === AGENT_MODEL || id.startsWith(AGENT_MODEL + '/'))
|
|
483
|
+
function agentTargetOf(id) {
|
|
484
|
+
if (!isAgentModelId(id) || id === AGENT_MODEL) return null
|
|
485
|
+
const rest = id.slice(AGENT_MODEL.length + 1)
|
|
486
|
+
const i = rest.indexOf('/')
|
|
487
|
+
return i > 0 && i < rest.length - 1 ? { provider: rest.slice(0, i), model: rest.slice(i + 1) } : null
|
|
488
|
+
}
|
|
489
|
+
// the picker groups every agent-run entry under this key (the SPA's AGENT_PROVIDER)
|
|
490
|
+
const AGENT_GROUP = '__agent__'
|
|
452
491
|
|
|
453
492
|
/** Which of a provider's models take images: the adapter's declared input
|
|
454
493
|
* modalities; a name that says "vision" only when the adapter says nothing. */
|
|
@@ -465,11 +504,18 @@ async function visionIdsOf(ctx, providerId, catalogModels) {
|
|
|
465
504
|
async function modelsPayload(ctx) {
|
|
466
505
|
const cat = await ctx.sessionController.modelCatalog()
|
|
467
506
|
// the harness itself, as an entry: the agent loop with tools, not a bare model
|
|
468
|
-
|
|
507
|
+
// the harness's agent loop with tools, once per model it can run on —
|
|
508
|
+
// grouped with the other agent runtimes; the bare models follow by provider
|
|
509
|
+
const models = []
|
|
510
|
+
const agents = []
|
|
469
511
|
for (const g of cat.groups ?? []) {
|
|
470
512
|
const vision = await visionIdsOf(ctx, g.id, g.models ?? [])
|
|
471
|
-
for (const m of g.models ?? [])
|
|
513
|
+
for (const m of g.models ?? []) {
|
|
514
|
+
models.push({ id: `${g.id}/${m.id}`, name: m.name ?? m.id, provider: g.name ?? g.id, vision: vision.has(m.id) })
|
|
515
|
+
agents.push({ id: `${AGENT_MODEL}/${g.id}/${m.id}`, name: `Harness · ${m.name ?? m.id}`, provider: AGENT_GROUP, vision: vision.has(m.id) })
|
|
516
|
+
}
|
|
472
517
|
}
|
|
518
|
+
models.push(...agents)
|
|
473
519
|
const def = cat.default ? `${cat.default.provider}/${cat.default.model}` : null
|
|
474
520
|
return {
|
|
475
521
|
models,
|
|
@@ -617,6 +663,10 @@ async function runAgentTurn(ctx, body, emit, isClosed, { answerApprovals = true
|
|
|
617
663
|
const vm = await visionModel(ctx).catch(() => null)
|
|
618
664
|
if (vm) await ctx.sessionController.selectModel({ sessionId, provider: vm.provider, model: vm.model }).catch(() => {})
|
|
619
665
|
}
|
|
666
|
+
// the model the canvas picked for this agent turn, unless images already
|
|
667
|
+
// moved the session to a vision model
|
|
668
|
+
const picked = agentTargetOf(body?.model)
|
|
669
|
+
if (picked && !hasImages) await ctx.sessionController.selectModel({ sessionId, provider: picked.provider, model: picked.model }).catch(() => {})
|
|
620
670
|
const agent = await agentOf(ctx, sessionId)
|
|
621
671
|
let sawChunk = false
|
|
622
672
|
let fullText = ''
|
|
@@ -834,6 +884,7 @@ export async function apply(ctx, config) {
|
|
|
834
884
|
return sendJson(res, 200, { session: sessionSummary(session) })
|
|
835
885
|
}
|
|
836
886
|
if (path === '/why/status' && req.method === 'GET') return sendJson(res, 200, whyStatus)
|
|
887
|
+
if (path === '/version' && req.method === 'GET') return sendJson(res, 200, { version: PLUGIN_VERSION, latest: await latestPluginVersion(), checkedAt: latestLookup.at || null })
|
|
837
888
|
// ── the other agents' session files ──
|
|
838
889
|
if (path === '/roots' && req.method === 'GET') {
|
|
839
890
|
const roots = []
|
|
@@ -870,6 +921,11 @@ export async function apply(ctx, config) {
|
|
|
870
921
|
: String(r.body?.content ?? '')
|
|
871
922
|
return sendJson(res, 200, { title, text, fetchedAt: new Date().toISOString(), ...(html ? { html } : {}), url: r.url ?? target, truncated: !!r.truncated })
|
|
872
923
|
}
|
|
924
|
+
// ── agent runtimes on this machine (Pi today), the same surface the desktop shell has ──
|
|
925
|
+
if (path.startsWith('/agents/')) {
|
|
926
|
+
const body = req.method === 'POST' ? await readJson(req, MAX_WRITE_BODY_BYTES).catch(() => null) : null
|
|
927
|
+
if (await agentsHttp().handle(req, res, path, body)) return
|
|
928
|
+
}
|
|
873
929
|
// ── model connection (the SPA's proxy protocol, on the harness's providers) ──
|
|
874
930
|
if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx))
|
|
875
931
|
const approvalRoute = /^\/approvals\/([^/]+)$/.exec(path)
|
|
@@ -884,10 +940,10 @@ export async function apply(ctx, config) {
|
|
|
884
940
|
}
|
|
885
941
|
if ((path === '/stream' || path === '/claude') && req.method === 'POST') {
|
|
886
942
|
const body = await readJson(req, MAX_CALL_BODY_BYTES)
|
|
887
|
-
if (body?.model
|
|
943
|
+
if (isAgentModelId(body?.model)) {
|
|
888
944
|
if (path === '/claude') {
|
|
889
945
|
const r = await runAgentTurn(ctx, body, () => {}, () => false, { answerApprovals: false })
|
|
890
|
-
return sendJson(res, 200, { text: r.text, model:
|
|
946
|
+
return sendJson(res, 200, { text: r.text, model: body.model, harnessSession: r.sessionId })
|
|
891
947
|
}
|
|
892
948
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
893
949
|
let closed = false
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Agent runtimes
|
|
2
|
+
|
|
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`. Each exports a factory returning the same surface:
|
|
4
|
+
|
|
5
|
+
- `available()` → where the binary is, or null
|
|
6
|
+
- `models()` → `{ installed, models: [{ provider, id, name, reasoning, vision }], default }`
|
|
7
|
+
- `run(request, onEvent)` → run id at once; events follow, tagged with it
|
|
8
|
+
- `abort(runId)`, `answer(runId, requestId, { confirmed } | { value } | { cancelled: true })`, `shutdown()`
|
|
9
|
+
|
|
10
|
+
A run emits exactly these event kinds (the renderer reads nothing else):
|
|
11
|
+
|
|
12
|
+
| kind | meaning |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `session` | which session file this run writes to, and where (cwd) |
|
|
15
|
+
| `message_update` with a text or thinking delta | the answer or the reasoning growing |
|
|
16
|
+
| `tool_execution_start` / `tool_execution_end` | a tool call, for the live trace |
|
|
17
|
+
| `question` | the agent asks the person: confirm / select / input / editor |
|
|
18
|
+
| `question_answered` | the person's answer went back (record) |
|
|
19
|
+
| `fs_changes` | what the shell saw change on disk (produced here, not by the agent) |
|
|
20
|
+
| `run_end` / `run_error` | the turn is over |
|
|
21
|
+
|
|
22
|
+
The finished turn is adopted from the session file by the atlas's adapter for that
|
|
23
|
+
runtime; the stream is for showing, the file is the truth. Anything a runtime does
|
|
24
|
+
beyond these kinds stays inside its own file.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Codex as an agent runtime: one long-lived `codex app-server` (JSON-RPC 2.0
|
|
2
|
+
// over stdio, newline-delimited). A run is one turn on one thread: fresh
|
|
3
|
+
// (`thread/start` in the working directory), continued (`thread/resume`),
|
|
4
|
+
// or branched (`thread/fork`). The server's notifications become the same
|
|
5
|
+
// event kinds the Pi runtime emits, so the renderer reads nothing new:
|
|
6
|
+
// text and reasoning deltas, tool starts and ends, questions (the server's
|
|
7
|
+
// own approval and user-input requests), run_end. Codex keeps its rollout
|
|
8
|
+
// files, login and model list; the shell only asks.
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { spawn } = require('node:child_process');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const { randomUUID } = require('node:crypto');
|
|
16
|
+
const { snapshotDir, diffSnapshots } = require('./fs-diff.cjs');
|
|
17
|
+
|
|
18
|
+
const fsp = fs.promises;
|
|
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')];
|
|
21
|
+
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The guard file the canvas writes per working directory, as Codex policy. */
|
|
35
|
+
async function policyFor(cwd) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(await fsp.readFile(path.join(cwd, '.thoughtdag', 'guard.json'), 'utf8'));
|
|
38
|
+
if (raw?.mode === 'allow') return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
|
|
39
|
+
} catch { /* default */ }
|
|
40
|
+
return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const threadIdOf = (sessionPath) => {
|
|
44
|
+
const m = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(String(sessionPath ?? ''));
|
|
45
|
+
return m ? m[1] : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function createCodexRuntime({ log } = {}) {
|
|
49
|
+
const say = log || (() => {});
|
|
50
|
+
let proc = null; // the app-server child
|
|
51
|
+
let ready = null; // initialize handshake
|
|
52
|
+
let buf = '';
|
|
53
|
+
let nextId = 1;
|
|
54
|
+
const pending = new Map(); // our request id → resolve
|
|
55
|
+
const runs = new Map(); // runId → run state
|
|
56
|
+
const byThread = new Map(); // threadId → runId
|
|
57
|
+
const serverAsks = new Map(); // question id → { rpcId, respond(answer) }
|
|
58
|
+
let binPromise = null;
|
|
59
|
+
const bin = () => { if (!binPromise) binPromise = findCodex(); return binPromise; };
|
|
60
|
+
|
|
61
|
+
const write = (obj) => { try { proc.stdin.write(JSON.stringify(obj) + '\n'); return true; } catch { return false; } };
|
|
62
|
+
const send = (method, params, timeoutMs = RESPONSE_MS) => new Promise((resolve, reject) => {
|
|
63
|
+
if (!proc) return reject(new Error('codex app-server is not running'));
|
|
64
|
+
const id = nextId++;
|
|
65
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`codex did not answer ${method} in ${timeoutMs / 1000}s`)); }, timeoutMs);
|
|
66
|
+
pending.set(id, (msg) => { clearTimeout(timer); if (msg.error) reject(new Error(msg.error.message || `codex refused ${method}`)); else resolve(msg.result); });
|
|
67
|
+
if (!write({ jsonrpc: '2.0', id, method, params })) { clearTimeout(timer); pending.delete(id); reject(new Error('codex stdin closed')); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const die = (reason) => {
|
|
71
|
+
for (const p of pending.values()) p({ error: { message: reason } });
|
|
72
|
+
pending.clear();
|
|
73
|
+
for (const r of runs.values()) { r.emit({ type: 'run_error', message: reason }); r.end('exit'); }
|
|
74
|
+
proc = null; ready = null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
async function ensureServer() {
|
|
78
|
+
if (proc && ready) return ready;
|
|
79
|
+
const b = await bin();
|
|
80
|
+
if (!b) throw new Error('codex is not installed (no `codex` on PATH or in the usual places)');
|
|
81
|
+
proc = spawn(b, ['app-server'], { env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
82
|
+
buf = '';
|
|
83
|
+
proc.stdout.on('data', (d) => {
|
|
84
|
+
buf += d.toString();
|
|
85
|
+
let i;
|
|
86
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
87
|
+
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
|
88
|
+
if (!line.trim()) continue;
|
|
89
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
90
|
+
dispatch(msg);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
proc.stderr.on('data', (d) => say('[codex stderr] ' + d.toString().trim().slice(0, 400)));
|
|
94
|
+
proc.on('error', (e) => die('spawn error: ' + e.message));
|
|
95
|
+
proc.on('exit', (code, signal) => die(`codex exited (${signal || code})`));
|
|
96
|
+
ready = send('initialize', { clientInfo: { name: 'thoughtdag', title: 'ThoughtDAG', version: '0.5.0' }, capabilities: null })
|
|
97
|
+
.then(() => { write({ jsonrpc: '2.0', method: 'initialized', params: {} }); });
|
|
98
|
+
return ready;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const runOf = (threadId) => { const id = byThread.get(threadId); return id ? runs.get(id) : undefined; };
|
|
102
|
+
|
|
103
|
+
// ── the server speaks: responses, notifications, and its own requests ──
|
|
104
|
+
function dispatch(msg) {
|
|
105
|
+
if (msg.id !== undefined && msg.method === undefined) { const p = pending.get(msg.id); if (p) { pending.delete(msg.id); p(msg); } return; }
|
|
106
|
+
const params = msg.params ?? {};
|
|
107
|
+
const run = runOf(params.threadId);
|
|
108
|
+
if (msg.id !== undefined && msg.method) { serverRequest(msg, run); return; }
|
|
109
|
+
if (!run) return;
|
|
110
|
+
switch (msg.method) {
|
|
111
|
+
case 'item/agentMessage/delta':
|
|
112
|
+
run.text += String(params.delta ?? '');
|
|
113
|
+
run.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: String(params.delta ?? '') } });
|
|
114
|
+
break;
|
|
115
|
+
case 'item/reasoning/textDelta':
|
|
116
|
+
case 'item/reasoning/summaryTextDelta':
|
|
117
|
+
run.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: String(params.delta ?? '') } });
|
|
118
|
+
break;
|
|
119
|
+
case 'item/started': {
|
|
120
|
+
const it = params.item ?? {};
|
|
121
|
+
const t = toolOf(it);
|
|
122
|
+
if (t) run.emit({ type: 'tool_execution_start', toolCallId: it.id, toolName: t.name, args: t.args });
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'item/completed': {
|
|
126
|
+
const it = params.item ?? {};
|
|
127
|
+
if (it.type === 'agentMessage' && typeof it.text === 'string') run.final = it.text;
|
|
128
|
+
const t = toolOf(it);
|
|
129
|
+
if (t) run.emit({ type: 'tool_execution_end', toolCallId: it.id, toolName: t.name, result: t.result, isError: t.isError });
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'turn/completed': {
|
|
133
|
+
const turn = params.turn ?? {};
|
|
134
|
+
if (turn.status === 'failed') run.emit({ type: 'run_error', message: turn.error?.message ?? 'the turn failed' });
|
|
135
|
+
run.end(turn.status === 'interrupted' ? 'aborted' : turn.status === 'failed' ? 'error' : 'end');
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
default: break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A thread item as a tool call, for the trace and the record. */
|
|
143
|
+
function toolOf(it) {
|
|
144
|
+
switch (it.type) {
|
|
145
|
+
case 'commandExecution': return { name: 'bash', args: { command: it.command, cwd: it.cwd }, result: it.aggregatedOutput ?? null, isError: it.status === 'failed' || (typeof it.exitCode === 'number' && it.exitCode !== 0) };
|
|
146
|
+
case 'fileChange': return { name: 'edit', args: { path: (it.changes ?? []).map((c) => c.path).join(', ') }, result: it.status ?? null, isError: it.status === 'failed' || it.status === 'declined' };
|
|
147
|
+
case 'mcpToolCall': return { name: `${it.server}/${it.tool}`, args: it.arguments ?? {}, result: it.result ?? it.error ?? null, isError: !!it.error };
|
|
148
|
+
case 'dynamicToolCall': return { name: it.tool, args: it.arguments ?? {}, result: null, isError: it.status === 'failed' };
|
|
149
|
+
case 'webSearch': return { name: 'web_search', args: { query: it.query ?? '' }, result: null, isError: false };
|
|
150
|
+
default: return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── the server asks the person: approvals and user input ──
|
|
155
|
+
function serverRequest(msg, run) {
|
|
156
|
+
const reply = (result) => write({ jsonrpc: '2.0', id: msg.id, result });
|
|
157
|
+
const params = msg.params ?? {};
|
|
158
|
+
if (!run) {
|
|
159
|
+
// nobody at the canvas for this thread: fail closed
|
|
160
|
+
if (msg.method === 'item/tool/requestUserInput') return reply({ answers: {} });
|
|
161
|
+
if (msg.method === 'item/permissions/requestApproval') return reply({ permissions: {}, scope: 'turn' });
|
|
162
|
+
return reply({ decision: 'decline' });
|
|
163
|
+
}
|
|
164
|
+
const ask = (question, respond) => {
|
|
165
|
+
const id = `q-${msg.id}`;
|
|
166
|
+
serverAsks.set(id, { rpcId: msg.id, respond });
|
|
167
|
+
run.waiting = true;
|
|
168
|
+
run.emit({ type: 'question', id, ...question });
|
|
169
|
+
};
|
|
170
|
+
const decided = (id, outcome, value) => { run.waiting = false; run.emit({ type: 'question_answered', id, outcome, value: value ?? null }); };
|
|
171
|
+
switch (msg.method) {
|
|
172
|
+
case 'item/commandExecution/requestApproval':
|
|
173
|
+
return ask({ kind: 'confirm', title: 'command needs approval', message: [params.command, params.reason].filter(Boolean).join('\n'), options: [], placeholder: null, prefill: null, paths: [], suggest: null },
|
|
174
|
+
(a, id) => { const yes = !!a?.confirmed; reply({ decision: yes ? 'accept' : 'decline' }); decided(id, yes ? 'allowed-once' : 'rejected'); });
|
|
175
|
+
case 'item/fileChange/requestApproval':
|
|
176
|
+
return ask({ kind: 'confirm', title: 'file change needs approval', message: [params.reason, params.grantRoot].filter(Boolean).join('\n') || 'apply the proposed file changes', options: [], placeholder: null, prefill: null, paths: [], suggest: null },
|
|
177
|
+
(a, id) => { const yes = !!a?.confirmed; reply({ decision: yes ? 'accept' : 'decline' }); decided(id, yes ? 'allowed-once' : 'rejected'); });
|
|
178
|
+
case 'item/permissions/requestApproval':
|
|
179
|
+
return ask({ kind: 'confirm', title: 'more permissions requested', message: [params.reason, JSON.stringify(params.permissions ?? {})].filter(Boolean).join('\n'), options: [], placeholder: null, prefill: null, paths: [], suggest: null },
|
|
180
|
+
(a, id) => { const yes = !!a?.confirmed; reply({ permissions: yes ? (params.permissions ?? {}) : {}, scope: 'turn' }); decided(id, yes ? 'allowed-once' : 'rejected'); });
|
|
181
|
+
case 'item/tool/requestUserInput': {
|
|
182
|
+
// one question at a time; the answers go back together
|
|
183
|
+
const qs = Array.isArray(params.questions) ? params.questions : [];
|
|
184
|
+
const answers = {};
|
|
185
|
+
const next = (i) => {
|
|
186
|
+
if (i >= qs.length) { reply({ answers }); return; }
|
|
187
|
+
const q = qs[i];
|
|
188
|
+
const opts = (q.options ?? []).map((o) => o.label);
|
|
189
|
+
ask({ kind: opts.length ? 'select' : 'input', title: q.header || 'question', message: q.question || '', options: opts, placeholder: q.isOther ? 'other…' : null, prefill: null, paths: [], suggest: null },
|
|
190
|
+
(a, id) => { const v = typeof a?.value === 'string' ? a.value : ''; answers[q.id] = { answers: v ? [v] : [] }; decided(id, v ? 'answered' : 'cancelled', v || null); next(i + 1); });
|
|
191
|
+
};
|
|
192
|
+
return next(0);
|
|
193
|
+
}
|
|
194
|
+
default:
|
|
195
|
+
return write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `unsupported request ${msg.method}` } });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const modelEntry = (m) => ({ provider: 'codex', id: m.model ?? m.id, name: m.displayName ?? m.model ?? m.id, reasoning: true, vision: true });
|
|
200
|
+
// the model a run uses when the canvas named none: the server's own
|
|
201
|
+
// default from model/list, not the config file's — a config can name a
|
|
202
|
+
// model this CLI version cannot run, and the list only carries runnable ones
|
|
203
|
+
let defaultModel = null;
|
|
204
|
+
async function defaultModelId() {
|
|
205
|
+
if (defaultModel) return defaultModel;
|
|
206
|
+
try { const r = await send('model/list', {}); const d = (r?.data ?? []).find((m) => m.isDefault) ?? (r?.data ?? [])[0]; defaultModel = d ? (d.model ?? d.id) : null; } catch { /* leave it to the config */ }
|
|
207
|
+
return defaultModel;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
available: () => bin(),
|
|
212
|
+
|
|
213
|
+
async models() {
|
|
214
|
+
const b = await bin();
|
|
215
|
+
if (!b) return { installed: false, models: [], default: null };
|
|
216
|
+
await ensureServer();
|
|
217
|
+
const r = await send('model/list', {});
|
|
218
|
+
const models = (r?.data ?? []).filter((m) => !m.hidden).map(modelEntry);
|
|
219
|
+
const def = (r?.data ?? []).find((m) => m.isDefault);
|
|
220
|
+
defaultModel = def ? (def.model ?? def.id) : defaultModel;
|
|
221
|
+
return { installed: true, models, default: def ? `codex/${def.model ?? def.id}` : (models[0] ? `codex/${models[0].id}` : null) };
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async run(req, onEvent) {
|
|
225
|
+
const cwd = String(req.cwd || '');
|
|
226
|
+
if (!cwd || !path.isAbsolute(cwd)) throw new Error('run needs an absolute cwd');
|
|
227
|
+
await fsp.mkdir(cwd, { recursive: true });
|
|
228
|
+
await ensureServer();
|
|
229
|
+
const runId = randomUUID();
|
|
230
|
+
const emit = (event) => { try { onEvent({ runId, event }); } catch { /* renderer gone */ } };
|
|
231
|
+
const state = { runId, threadId: null, turnId: null, text: '', final: null, waiting: false, emit, end: null, done: null };
|
|
232
|
+
state.done = new Promise((resolve) => { state.end = (how) => { if (state.ended) return; state.ended = true; resolve(how); }; });
|
|
233
|
+
runs.set(runId, state);
|
|
234
|
+
void (async () => {
|
|
235
|
+
const IDLE = Number(process.env.TD_AGENT_IDLE_MS) || 10 * 60 * 1000;
|
|
236
|
+
let lastEvent = Date.now();
|
|
237
|
+
const origEmit = state.emit;
|
|
238
|
+
state.emit = (event) => { lastEvent = Date.now(); if (event.type === 'question') state.waiting = true; if (event.type === 'question_answered') state.waiting = false; origEmit(event); };
|
|
239
|
+
const watchdog = setInterval(() => {
|
|
240
|
+
if (state.waiting || Date.now() - lastEvent < IDLE) return;
|
|
241
|
+
state.emit({ type: 'run_error', message: `no activity for ${IDLE >= 60000 ? Math.round(IDLE / 60000) + ' min' : Math.round(IDLE / 1000) + ' s'}; the turn was stopped` });
|
|
242
|
+
if (state.threadId && state.turnId) send('turn/interrupt', { threadId: state.threadId, turnId: state.turnId }).catch(() => {});
|
|
243
|
+
setTimeout(() => state.end('idle'), 5000);
|
|
244
|
+
}, 15000);
|
|
245
|
+
try {
|
|
246
|
+
const policy = await policyFor(cwd);
|
|
247
|
+
const model = (req.model && typeof req.model === 'object' && req.model.id ? req.model.id : null) ?? await defaultModelId();
|
|
248
|
+
let thread;
|
|
249
|
+
const resumeId = req.sessionPath ? threadIdOf(req.sessionPath) : null;
|
|
250
|
+
if (req.forkEntryId && resumeId) thread = (await send('thread/fork', { threadId: resumeId, cwd, ...policy, ...(model ? { model } : {}) })).thread;
|
|
251
|
+
else if (resumeId) thread = (await send('thread/resume', { threadId: resumeId, cwd, ...policy, ...(model ? { model } : {}) })).thread;
|
|
252
|
+
else thread = (await send('thread/start', { cwd, ...policy, ...(model ? { model } : {}) })).thread;
|
|
253
|
+
state.threadId = thread.id;
|
|
254
|
+
byThread.set(thread.id, runId);
|
|
255
|
+
state.emit({ type: 'session', sessionId: thread.id, sessionFile: thread.path ?? null, model: model ? { provider: 'codex', id: model, name: model } : null, cwd });
|
|
256
|
+
const before = await snapshotDir(cwd).catch(() => null);
|
|
257
|
+
const input = [{ type: 'text', text: String(req.prompt ?? ''), text_elements: [] }];
|
|
258
|
+
for (const img of Array.isArray(req.images) ? req.images : []) if (img && typeof img.data === 'string') input.push({ type: 'image', url: `data:${img.mimeType};base64,${img.data}` });
|
|
259
|
+
const started = await send('turn/start', { threadId: thread.id, input, ...(model ? { model } : {}) }, 60 * 1000);
|
|
260
|
+
state.turnId = started?.turn?.id ?? null;
|
|
261
|
+
const how = await state.done;
|
|
262
|
+
if (before) {
|
|
263
|
+
const after = await snapshotDir(cwd).catch(() => null);
|
|
264
|
+
if (after) { const d = diffSnapshots(before, after); if (d.changed.length || d.added.length || d.removed.length || d.truncated) state.emit({ type: 'fs_changes', ...d }); }
|
|
265
|
+
}
|
|
266
|
+
state.emit({ type: 'run_end', text: state.final ?? state.text, how });
|
|
267
|
+
} catch (e) {
|
|
268
|
+
state.emit({ type: 'run_error', message: e instanceof Error ? e.message : String(e) });
|
|
269
|
+
state.emit({ type: 'run_end', text: state.final ?? state.text, how: 'error' });
|
|
270
|
+
} finally {
|
|
271
|
+
clearInterval(watchdog);
|
|
272
|
+
if (state.threadId) byThread.delete(state.threadId);
|
|
273
|
+
runs.delete(runId);
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
return runId;
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
abort(runId) {
|
|
280
|
+
const r = runs.get(runId);
|
|
281
|
+
if (!r) return false;
|
|
282
|
+
// withdraw any question still open for this run, then interrupt
|
|
283
|
+
for (const [id, ask] of serverAsks) { if (id.startsWith('q-')) { /* the server's own timeout covers it */ } void ask; }
|
|
284
|
+
if (r.threadId && r.turnId) send('turn/interrupt', { threadId: r.threadId, turnId: r.turnId }).catch(() => {});
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
answer(runId, requestId, response) {
|
|
289
|
+
const ask = serverAsks.get(String(requestId));
|
|
290
|
+
if (!ask || !runs.has(runId)) return false;
|
|
291
|
+
serverAsks.delete(String(requestId));
|
|
292
|
+
const a = response && typeof response === 'object' ? response : { confirmed: !!response };
|
|
293
|
+
if (a.cancelled) { ask.respond({ confirmed: false, value: '' }, String(requestId)); return true; }
|
|
294
|
+
ask.respond(a, String(requestId));
|
|
295
|
+
return true;
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
shutdown() { try { proc?.kill(); } catch { /* gone */ } proc = null; ready = null; },
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { createCodexRuntime, findCodex };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What changed on disk during a turn, for any runtime: a snapshot of the
|
|
2
|
+
// working directory before the prompt and after the turn; the difference is
|
|
3
|
+
// what the turn changed, whatever tool did it. Bounded; bulk directories
|
|
4
|
+
// are skipped.
|
|
5
|
+
'use strict';
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const fsp = require('node:fs/promises');
|
|
8
|
+
|
|
9
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '.thoughtdag', 'dist', 'build', 'target', '.venv', 'venv', '__pycache__', '.cache', '.next', '.turbo', 'out', 'coverage']);
|
|
10
|
+
const SNAPSHOT_MAX_FILES = 20000;
|
|
11
|
+
const SNAPSHOT_MAX_DEPTH = 12;
|
|
12
|
+
|
|
13
|
+
async function snapshotDir(root) {
|
|
14
|
+
const files = new Map();
|
|
15
|
+
let truncated = false;
|
|
16
|
+
const walk = async (dir, depth) => {
|
|
17
|
+
if (truncated || depth > SNAPSHOT_MAX_DEPTH) return;
|
|
18
|
+
let entries;
|
|
19
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
if (truncated) return;
|
|
22
|
+
const p = path.join(dir, e.name);
|
|
23
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) await walk(p, depth + 1); continue; }
|
|
24
|
+
if (!e.isFile()) continue;
|
|
25
|
+
try { const st = await fsp.stat(p); files.set(p, `${st.mtimeMs}:${st.size}`); } catch { /* vanished */ }
|
|
26
|
+
if (files.size >= SNAPSHOT_MAX_FILES) truncated = true;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
await walk(root, 0);
|
|
30
|
+
return { files, truncated };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function diffSnapshots(before, after) {
|
|
34
|
+
const changed = []; const added = []; const removed = [];
|
|
35
|
+
for (const [p, sig] of after.files) {
|
|
36
|
+
const prev = before.files.get(p);
|
|
37
|
+
if (prev === undefined) added.push(p); else if (prev !== sig) changed.push(p);
|
|
38
|
+
}
|
|
39
|
+
for (const p of before.files.keys()) if (!after.files.has(p)) removed.push(p);
|
|
40
|
+
return { changed, added, removed, truncated: before.truncated || after.truncated };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
module.exports = { snapshotDir, diffSnapshots };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The agents surface over HTTP, for hosts that are not the desktop shell:
|
|
2
|
+
// the harness plugin's host and the local server. Same calls as
|
|
3
|
+
// window.desktopAgents, one endpoint each, and one server-sent-events feed
|
|
4
|
+
// carrying every run's events (the renderer's shim turns it back into
|
|
5
|
+
// onEvent). Runtimes are created on first use, as in the shell.
|
|
6
|
+
'use strict';
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const ops = require('./ops.cjs');
|
|
10
|
+
const terminal = require('../terminal.cjs');
|
|
11
|
+
|
|
12
|
+
const RUNTIME_FACTORIES = {
|
|
13
|
+
pi: (log) => require('./pi.cjs').createPiRuntime({ log }),
|
|
14
|
+
codex: (log) => require('./codex.cjs').createCodexRuntime({ log }),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function createAgentsHttp({ workspaceRoot = path.join(os.homedir(), '.thoughtdag', 'workspaces'), log = () => {} } = {}) {
|
|
18
|
+
const runtimes = new Map();
|
|
19
|
+
const owners = new Map(); // runId → runtime name
|
|
20
|
+
const clients = new Set(); // SSE responses
|
|
21
|
+
const runtime = (name = 'pi') => {
|
|
22
|
+
const key = RUNTIME_FACTORIES[name] ? name : 'pi';
|
|
23
|
+
if (!runtimes.has(key)) runtimes.set(key, RUNTIME_FACTORIES[key](log));
|
|
24
|
+
return runtimes.get(key);
|
|
25
|
+
};
|
|
26
|
+
const broadcast = (payload) => {
|
|
27
|
+
const line = `data: ${JSON.stringify(payload)}\n\n`;
|
|
28
|
+
for (const res of clients) { try { res.write(line); } catch { clients.delete(res); } }
|
|
29
|
+
};
|
|
30
|
+
const heartbeat = setInterval(() => { for (const res of clients) { try { res.write(': hb\n\n'); } catch { clients.delete(res); } } }, 20000);
|
|
31
|
+
heartbeat.unref?.();
|
|
32
|
+
|
|
33
|
+
const json = (res, status, body) => {
|
|
34
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
35
|
+
res.end(JSON.stringify(body));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Handle one request. `sub` is the path after the mount point, starting
|
|
40
|
+
* with `/agents`; `body` is the parsed JSON body (or null). Returns false
|
|
41
|
+
* when the path is not ours.
|
|
42
|
+
*/
|
|
43
|
+
async function handle(req, res, sub, body) {
|
|
44
|
+
const url = new URL(req.url ?? '/', 'http://local');
|
|
45
|
+
const p = sub.replace(/\/+$/, '');
|
|
46
|
+
const method = req.method ?? 'GET';
|
|
47
|
+
try {
|
|
48
|
+
if (p === '/agents/available' && method === 'GET') {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const name of Object.keys(RUNTIME_FACTORIES)) out[name] = await runtime(name).available().catch(() => null);
|
|
51
|
+
return json(res, 200, out), true;
|
|
52
|
+
}
|
|
53
|
+
if (p === '/agents/models' && method === 'GET') {
|
|
54
|
+
try { return json(res, 200, await runtime(url.searchParams.get('runtime') ?? 'pi').models()), true; }
|
|
55
|
+
catch (e) { return json(res, 200, { installed: false, models: [], default: null, error: e instanceof Error ? e.message : String(e) }), true; }
|
|
56
|
+
}
|
|
57
|
+
if (p === '/agents/events' && method === 'GET') {
|
|
58
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
59
|
+
res.write(': connected\n\n');
|
|
60
|
+
clients.add(res);
|
|
61
|
+
req.on('close', () => clients.delete(res));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (p === '/agents/run' && method === 'POST') {
|
|
65
|
+
const r = body && typeof body === 'object' ? body : {};
|
|
66
|
+
const name = RUNTIME_FACTORIES[r.runtime] ? r.runtime : 'pi';
|
|
67
|
+
const runId = await runtime(name).run(r, broadcast);
|
|
68
|
+
owners.set(runId, name);
|
|
69
|
+
return json(res, 200, { runId }), true;
|
|
70
|
+
}
|
|
71
|
+
if (p === '/agents/abort' && method === 'POST') {
|
|
72
|
+
const id = String(body?.runId ?? '');
|
|
73
|
+
return json(res, 200, { ok: runtime(owners.get(id)).abort(id) }), true;
|
|
74
|
+
}
|
|
75
|
+
if (p === '/agents/answer' && method === 'POST') {
|
|
76
|
+
const id = String(body?.runId ?? '');
|
|
77
|
+
return json(res, 200, { ok: runtime(owners.get(id)).answer(id, String(body?.requestId ?? ''), body?.response) }), true;
|
|
78
|
+
}
|
|
79
|
+
if (p === '/agents/workspace' && method === 'POST') return json(res, 200, { dir: await ops.workspaceFor(workspaceRoot, String(body?.canvasId ?? 'default')) }), true;
|
|
80
|
+
if (p === '/agents/guard' && method === 'POST') return json(res, 200, { ok: await ops.writeGuard(String(body?.cwd ?? ''), body?.config) }), true;
|
|
81
|
+
if (p === '/agents/materials' && method === 'POST') return json(res, 200, await ops.writeMaterials(String(body?.cwd ?? ''), body?.files)), true;
|
|
82
|
+
if (p === '/agents/open-in-cli' && method === 'POST') return json(res, 200, await terminal.openInTerminal(String(body?.runner ?? ''), body?.cwd ?? null, String(body?.sessionId ?? ''))), true;
|
|
83
|
+
return false;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
json(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const shutdown = () => { clearInterval(heartbeat); for (const r of runtimes.values()) r.shutdown(); for (const res of clients) { try { res.end(); } catch { /* gone */ } } };
|
|
91
|
+
return { handle, shutdown };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { createAgentsHttp };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What every host does around a run, in plain Node: the per-canvas
|
|
2
|
+
// workspace directory, the canvas's materials written where the agent can
|
|
3
|
+
// read them, the boundary guard's tuning file. Used by the desktop shell,
|
|
4
|
+
// the harness plugin host and the local server alike.
|
|
5
|
+
'use strict';
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const fsp = require('node:fs/promises');
|
|
8
|
+
|
|
9
|
+
const WORKSPACE_ID = /^[\w.-]{1,80}$/;
|
|
10
|
+
const fileExists = (p) => fsp.access(p).then(() => true, () => false);
|
|
11
|
+
|
|
12
|
+
/** The shell-managed working directory of a canvas, created on demand. */
|
|
13
|
+
async function workspaceFor(root, canvasId) {
|
|
14
|
+
if (!WORKSPACE_ID.test(String(canvasId))) throw new Error('bad canvas id');
|
|
15
|
+
const dir = path.join(root, String(canvasId));
|
|
16
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
17
|
+
const readme = path.join(dir, 'README.md');
|
|
18
|
+
if (!(await fileExists(readme))) {
|
|
19
|
+
await fsp.writeFile(readme, `# ThoughtDAG workspace\n\nThis folder is the working directory of one ThoughtDAG canvas (id ${canvasId}).\nAgents launched from that canvas read, write and run commands here unless the\ncanvas mirrors a session that already has a working directory of its own.\n`);
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The canvas's materials under <cwd>/.thoughtdag/materials/<name>:
|
|
25
|
+
* text as-is, binaries from base64. */
|
|
26
|
+
async function writeMaterials(cwd, files) {
|
|
27
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd) || !Array.isArray(files)) return { dir: null, written: [] };
|
|
28
|
+
const dir = path.join(cwd, '.thoughtdag', 'materials');
|
|
29
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
30
|
+
const written = [];
|
|
31
|
+
for (const f of files.slice(0, 40)) {
|
|
32
|
+
const name = String(f?.name ?? '').replace(/[/\\:*?"<>|]/g, '_').slice(0, 120);
|
|
33
|
+
if (!name || typeof f.content !== 'string') continue;
|
|
34
|
+
const target = path.join(dir, name);
|
|
35
|
+
try {
|
|
36
|
+
await fsp.writeFile(target, f.encoding === 'base64' ? Buffer.from(f.content, 'base64') : f.content);
|
|
37
|
+
written.push(target);
|
|
38
|
+
} catch { /* one bad file does not stop the rest */ }
|
|
39
|
+
}
|
|
40
|
+
return { dir, written };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The guard's tuning for a working directory: <cwd>/.thoughtdag/guard.json */
|
|
44
|
+
async function writeGuard(cwd, config) {
|
|
45
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
|
|
46
|
+
const dir = path.join(cwd, '.thoughtdag');
|
|
47
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
48
|
+
const mode = config?.mode === 'allow' ? 'allow' : 'ask';
|
|
49
|
+
const allow = Array.isArray(config?.allow) ? config.allow.filter((x) => typeof x === 'string' && path.isAbsolute(x)).slice(0, 100) : [];
|
|
50
|
+
await fsp.writeFile(path.join(dir, 'guard.json'), JSON.stringify({ mode, allow }, null, 2));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { workspaceFor, writeMaterials, writeGuard };
|