dsh-thoughtdag 0.4.9 → 0.4.11

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 (35) hide show
  1. package/dist-app/assets/{canonical-Df0ckIdq.js → canonical-Cw94rYnE.js} +2 -2
  2. package/dist-app/assets/{canvas-record-CEy9ts1U.js → canvas-record-BYGoWCSi.js} +1 -1
  3. package/dist-app/assets/{claude-code-session-gxApIV4E.js → claude-code-session-D7_MMzYc.js} +1 -1
  4. package/dist-app/assets/{codex-session-rjwyINLc.js → codex-session-DVFc0JXs.js} +1 -1
  5. package/dist-app/assets/{dsh-session-9FrjljDY.js → dsh-session-BsZRbFip.js} +1 -1
  6. package/dist-app/assets/{experiment-loop-B8B2uvfc.js → experiment-loop-C9Q1W_wR.js} +1 -1
  7. package/dist-app/assets/http-bridge-mmwHcDuo.js +1 -0
  8. package/dist-app/assets/index-2pggDGtC.css +1 -0
  9. package/dist-app/assets/{index-Dbevq-w7.js → index-BEqQ5wV9.js} +1 -1
  10. package/dist-app/assets/{index-CCSkzpjj.js → index-Dyq9QwYZ.js} +3 -3
  11. package/dist-app/assets/index-Y8weIZcr.js +788 -0
  12. package/dist-app/assets/{index-DsWaXdBk.js → index-z0ZTt6U1.js} +1 -1
  13. package/dist-app/assets/{live-mirror-Bs_UiNg0.js → live-mirror-BlUlIINC.js} +2 -2
  14. package/dist-app/assets/{pi-session-_RCMhkfD.js → pi-session-wpL6N_gb.js} +1 -1
  15. package/dist-app/assets/{sensitive-scan-Dm_K56VK.js → sensitive-scan-Kz-yplyP.js} +1 -1
  16. package/dist-app/assets/{session-handoff-asoyabQy.js → session-handoff-BLHyTTl7.js} +2 -2
  17. package/dist-app/assets/{shared-BFDd9vhH.js → shared-BoxE2SdQ.js} +1 -1
  18. package/dist-app/assets/{turndown-plugin-gfm.cjs-DvWuwycM.js → turndown-plugin-gfm.cjs-2uYHu5V7.js} +1 -1
  19. package/dist-app/assets/{update-check-BhinDEBL.js → update-check-B1wesdxM.js} +1 -1
  20. package/dist-app/index.html +2 -2
  21. package/lib/client.js +8 -3
  22. package/lib/index.js +93 -7
  23. package/lib/runtime/agents/README.md +26 -0
  24. package/lib/runtime/agents/claude.cjs +303 -0
  25. package/lib/runtime/agents/codex.cjs +380 -0
  26. package/lib/runtime/agents/fs-diff.cjs +44 -0
  27. package/lib/runtime/agents/http.cjs +95 -0
  28. package/lib/runtime/agents/ops.cjs +54 -0
  29. package/lib/runtime/agents/pi-guard.mjs +81 -0
  30. package/lib/runtime/agents/pi.cjs +358 -0
  31. package/lib/runtime/sessions-fs.cjs +100 -0
  32. package/lib/runtime/terminal.cjs +105 -0
  33. package/package.json +1 -1
  34. package/dist-app/assets/index-BfIl0Oie.css +0 -1
  35. package/dist-app/assets/index-J-uxm9B7.js +0 -787
@@ -0,0 +1,380 @@
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
+ // the thread id is in the rollout's file name; take the LAST UUID in the
44
+ // path in case a directory carries one of its own
45
+ const threadIdOf = (sessionPath) => {
46
+ const all = String(sessionPath ?? '').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi);
47
+ return all && all.length ? all[all.length - 1] : null;
48
+ };
49
+
50
+ function createCodexRuntime({ log } = {}) {
51
+ const say = log || (() => {});
52
+ let proc = null; // the app-server child
53
+ let ready = null; // initialize handshake
54
+ let buf = '';
55
+ let nextId = 1;
56
+ const pending = new Map(); // our request id → resolve
57
+ const runs = new Map(); // runId → run state
58
+ const byThread = new Map(); // threadId → runId
59
+ const serverAsks = new Map(); // question id → { rpcId, respond(answer) }
60
+ let binPromise = null;
61
+ const bin = () => { if (!binPromise) binPromise = findCodex(); return binPromise; };
62
+
63
+ const write = (obj) => { try { proc.stdin.write(JSON.stringify(obj) + '\n'); return true; } catch { return false; } };
64
+ const send = (method, params, timeoutMs = RESPONSE_MS) => new Promise((resolve, reject) => {
65
+ if (!proc) return reject(new Error('codex app-server is not running'));
66
+ lastUse = Date.now();
67
+ const id = nextId++;
68
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`codex did not answer ${method} in ${timeoutMs / 1000}s`)); }, timeoutMs);
69
+ pending.set(id, (msg) => { clearTimeout(timer); if (msg.error) reject(new Error(msg.error.message || `codex refused ${method}`)); else resolve(msg.result); });
70
+ if (!write({ jsonrpc: '2.0', id, method, params })) { clearTimeout(timer); pending.delete(id); reject(new Error('codex stdin closed')); }
71
+ });
72
+
73
+ // The app-server retires after SERVER_IDLE_MS without a run or a request:
74
+ // a catalog read at picker time must not pin a hundred megabytes for the
75
+ // rest of the launch. The next run starts it again.
76
+ const SERVER_IDLE_MS = Number(process.env.TD_AGENT_IDLE_MS) || 10 * 60 * 1000;
77
+ let lastUse = Date.now();
78
+ const retire = setInterval(() => {
79
+ if (!proc || runs.size || pending.size || Date.now() - lastUse < SERVER_IDLE_MS) return;
80
+ say('codex app-server idle; stopping it');
81
+ try { proc.kill(); } catch { /* gone */ }
82
+ proc = null; ready = null;
83
+ }, 30 * 1000);
84
+ retire.unref?.();
85
+
86
+ const die = (reason) => {
87
+ for (const p of pending.values()) p({ error: { message: reason } });
88
+ pending.clear();
89
+ for (const r of runs.values()) { r.emit({ type: 'run_error', message: reason }); r.end('exit'); }
90
+ proc = null; ready = null;
91
+ };
92
+
93
+ async function ensureServer() {
94
+ if (proc && ready) return ready;
95
+ const b = await bin();
96
+ if (!b) throw new Error('codex is not installed (no `codex` on PATH or in the usual places)');
97
+ proc = spawn(b, ['app-server'], { env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
98
+ buf = '';
99
+ proc.stdout.on('data', (d) => {
100
+ buf += d.toString();
101
+ let i;
102
+ while ((i = buf.indexOf('\n')) >= 0) {
103
+ const line = buf.slice(0, i); buf = buf.slice(i + 1);
104
+ if (!line.trim()) continue;
105
+ let msg; try { msg = JSON.parse(line); } catch { continue; }
106
+ dispatch(msg);
107
+ }
108
+ });
109
+ proc.stderr.on('data', (d) => say('[codex stderr] ' + d.toString().trim().slice(0, 400)));
110
+ proc.on('error', (e) => die('spawn error: ' + e.message));
111
+ proc.on('exit', (code, signal) => die(`codex exited (${signal || code})`));
112
+ ready = send('initialize', { clientInfo: { name: 'thoughtdag', title: 'ThoughtDAG', version: '0.5.0' }, capabilities: null })
113
+ .then(() => { write({ jsonrpc: '2.0', method: 'initialized', params: {} }); });
114
+ return ready;
115
+ }
116
+
117
+ // the effort a turn ran at, as the rollout records it (turn_context.effort):
118
+ // the model's own default when the canvas named none
119
+ async function turnEffortOf(file) {
120
+ if (!file) return null;
121
+ try {
122
+ const lines = (await fsp.readFile(file, 'utf8')).split('\n');
123
+ for (let i = lines.length - 1; i >= 0; i--) {
124
+ if (!lines[i].includes('"turn_context"')) continue;
125
+ try { const o = JSON.parse(lines[i]); if (o.type === 'turn_context' && typeof o.payload?.effort === 'string') return o.payload.effort; } catch { /* torn line */ }
126
+ }
127
+ } catch { /* no file yet */ }
128
+ return null;
129
+ }
130
+
131
+ // ~/.codex/config.toml `model_reasoning_effort` overrides every model's own
132
+ // default; the picker's 'default' must name what a turn will actually use
133
+ async function configuredEffort() {
134
+ try {
135
+ const home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
136
+ const m = (await fsp.readFile(path.join(home, 'config.toml'), 'utf8')).match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m);
137
+ return m ? m[1] : null;
138
+ } catch { return null; }
139
+ }
140
+
141
+ const runOf = (threadId) => { const id = byThread.get(threadId); return id ? runs.get(id) : undefined; };
142
+
143
+ // ── the server speaks: responses, notifications, and its own requests ──
144
+ function dispatch(msg) {
145
+ lastUse = Date.now();
146
+ if (msg.id !== undefined && msg.method === undefined) { const p = pending.get(msg.id); if (p) { pending.delete(msg.id); p(msg); } return; }
147
+ const params = msg.params ?? {};
148
+ const run = runOf(params.threadId);
149
+ if (msg.id !== undefined && msg.method) { serverRequest(msg, run); return; }
150
+ if (!run) return;
151
+ switch (msg.method) {
152
+ case 'item/agentMessage/delta':
153
+ run.text += String(params.delta ?? '');
154
+ run.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: String(params.delta ?? '') } });
155
+ break;
156
+ case 'item/reasoning/textDelta':
157
+ case 'item/reasoning/summaryTextDelta':
158
+ run.emit({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: String(params.delta ?? '') } });
159
+ break;
160
+ case 'item/started': {
161
+ const it = params.item ?? {};
162
+ const t = toolOf(it);
163
+ if (t) run.emit({ type: 'tool_execution_start', toolCallId: it.id, toolName: t.name, args: t.args });
164
+ break;
165
+ }
166
+ case 'item/completed': {
167
+ const it = params.item ?? {};
168
+ if (it.type === 'agentMessage' && typeof it.text === 'string') run.final = it.text;
169
+ const t = toolOf(it);
170
+ if (t) run.emit({ type: 'tool_execution_end', toolCallId: it.id, toolName: t.name, result: t.result, isError: t.isError });
171
+ break;
172
+ }
173
+ case 'turn/completed': {
174
+ const turn = params.turn ?? {};
175
+ if (turn.status === 'failed') run.emit({ type: 'run_error', message: turn.error?.message ?? 'the turn failed' });
176
+ run.end(turn.status === 'interrupted' ? 'aborted' : turn.status === 'failed' ? 'error' : 'end');
177
+ break;
178
+ }
179
+ default: break;
180
+ }
181
+ }
182
+
183
+ /** A thread item as a tool call, for the trace and the record. */
184
+ function toolOf(it) {
185
+ switch (it.type) {
186
+ 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) };
187
+ 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' };
188
+ case 'mcpToolCall': return { name: `${it.server}/${it.tool}`, args: it.arguments ?? {}, result: it.result ?? it.error ?? null, isError: !!it.error };
189
+ case 'dynamicToolCall': return { name: it.tool, args: it.arguments ?? {}, result: null, isError: it.status === 'failed' };
190
+ case 'webSearch': return { name: 'web_search', args: { query: it.query ?? '' }, result: null, isError: false };
191
+ default: return null;
192
+ }
193
+ }
194
+
195
+ // ── the server asks the person: approvals and user input ──
196
+ function serverRequest(msg, run) {
197
+ const reply = (result) => write({ jsonrpc: '2.0', id: msg.id, result });
198
+ const params = msg.params ?? {};
199
+ if (!run) {
200
+ // nobody at the canvas for this thread: fail closed
201
+ if (msg.method === 'item/tool/requestUserInput') return reply({ answers: {} });
202
+ if (msg.method === 'item/permissions/requestApproval') return reply({ permissions: {}, scope: 'turn' });
203
+ return reply({ decision: 'decline' });
204
+ }
205
+ const ask = (question, respond) => {
206
+ const id = `q-${msg.id}`;
207
+ serverAsks.set(id, { rpcId: msg.id, respond });
208
+ run.waiting = true;
209
+ run.emit({ type: 'question', id, ...question });
210
+ };
211
+ const decided = (id, outcome, value, extra) => { run.waiting = false; run.emit({ type: 'question_answered', id, outcome, value: value ?? null, ...(extra ?? {}) }); };
212
+ // a yes/no the conversation may have settled already: `rule` names what a
213
+ // standing allowance covers; a match is accepted for the session without asking
214
+ const settle = (rule, title, message, respondYes) => {
215
+ if (rule && run.allowRules?.has(rule)) { respondYes(true); run.emit({ type: 'question_answered', id: `auto-${msg.id}`, outcome: 'allowed-session', value: null, auto: true, title, message, rule }); return true; }
216
+ return false;
217
+ };
218
+ const yesNo = (rule, respondYes, respondNo) => (a, id) => {
219
+ const yes = !!a?.confirmed; const forSession = yes && a?.scope === 'session' && !!rule;
220
+ if (forSession) run.allowRules?.add(rule);
221
+ if (yes) respondYes(forSession); else respondNo();
222
+ decided(id, yes ? (forSession ? 'allowed-session' : 'allowed-once') : 'rejected', null, forSession ? { rule } : undefined);
223
+ };
224
+ switch (msg.method) {
225
+ case 'item/commandExecution/requestApproval': {
226
+ const cmd = Array.isArray(params.command) ? params.command.join(' ') : String(params.command ?? '');
227
+ const rule = cmd ? 'codex:cmd:' + cmd : null;
228
+ const title = 'command needs approval'; const message = [params.command, params.reason].filter(Boolean).join('\n');
229
+ const respondYes = (forSession) => reply({ decision: forSession ? 'acceptForSession' : 'accept' });
230
+ if (settle(rule, title, message, respondYes)) return;
231
+ return ask({ kind: 'confirm', title, message, rule, options: [], placeholder: null, prefill: null, paths: [], suggest: null },
232
+ yesNo(rule, respondYes, () => reply({ decision: 'decline' })));
233
+ }
234
+ case 'item/fileChange/requestApproval': {
235
+ const rule = 'codex:fileChange';
236
+ const title = 'file change needs approval'; const message = [params.reason, params.grantRoot].filter(Boolean).join('\n') || 'apply the proposed file changes';
237
+ const respondYes = (forSession) => reply({ decision: forSession ? 'acceptForSession' : 'accept' });
238
+ if (settle(rule, title, message, respondYes)) return;
239
+ return ask({ kind: 'confirm', title, message, rule, options: [], placeholder: null, prefill: null, paths: [], suggest: null },
240
+ yesNo(rule, respondYes, () => reply({ decision: 'decline' })));
241
+ }
242
+ case 'item/permissions/requestApproval':
243
+ 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 },
244
+ (a, id) => { const yes = !!a?.confirmed; reply({ permissions: yes ? (params.permissions ?? {}) : {}, scope: 'turn' }); decided(id, yes ? 'allowed-once' : 'rejected'); });
245
+ case 'item/tool/requestUserInput': {
246
+ // one question at a time; the answers go back together
247
+ const qs = Array.isArray(params.questions) ? params.questions : [];
248
+ const answers = {};
249
+ const next = (i) => {
250
+ if (i >= qs.length) { reply({ answers }); return; }
251
+ const q = qs[i];
252
+ const opts = (q.options ?? []).map((o) => o.label);
253
+ 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 },
254
+ (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); });
255
+ };
256
+ return next(0);
257
+ }
258
+ default:
259
+ return write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `unsupported request ${msg.method}` } });
260
+ }
261
+ }
262
+
263
+ const effortsIn = (m) => (m.supportedReasoningEfforts ?? []).map((e) => (e && typeof e === 'object' ? e.reasoningEffort : e)).filter((e) => typeof e === 'string');
264
+ const modelEntry = (m) => ({ provider: 'codex', id: m.model ?? m.id, name: m.displayName ?? m.model ?? m.id, reasoning: true, vision: true, efforts: effortsIn(m), defaultEffort: typeof m.defaultReasoningEffort === 'string' ? m.defaultReasoningEffort : null });
265
+ // model id → the reasoning efforts it advertises, from model/list
266
+ const effortsByModel = new Map();
267
+ const noteEfforts = (r) => { for (const m of r?.data ?? []) effortsByModel.set(m.model ?? m.id, effortsIn(m)); };
268
+ // only a level the model itself advertises, in Codex's own words; anything
269
+ // else means the model's default
270
+ const effortFor = (model, word) => { const offered = effortsByModel.get(model); return word && offered && offered.includes(word) ? word : null; };
271
+ // the model a run uses when the canvas named none: the server's own
272
+ // default from model/list, not the config file's — a config can name a
273
+ // model this CLI version cannot run, and the list only carries runnable ones
274
+ let defaultModel = null;
275
+ async function defaultModelId() {
276
+ if (defaultModel) return defaultModel;
277
+ try { const r = await send('model/list', {}); noteEfforts(r); const d = (r?.data ?? []).find((m) => m.isDefault) ?? (r?.data ?? [])[0]; defaultModel = d ? (d.model ?? d.id) : null; } catch { /* leave it to the config */ }
278
+ return defaultModel;
279
+ }
280
+
281
+ return {
282
+ available: () => bin(),
283
+
284
+ async models() {
285
+ const b = await bin();
286
+ if (!b) return { installed: false, models: [], default: null };
287
+ await ensureServer();
288
+ const r = await send('model/list', {});
289
+ noteEfforts(r);
290
+ const configured = await configuredEffort();
291
+ const models = (r?.data ?? []).filter((m) => !m.hidden).map(modelEntry).map((m) => (configured && m.efforts.includes(configured) ? { ...m, defaultEffort: configured } : m));
292
+ const def = (r?.data ?? []).find((m) => m.isDefault);
293
+ defaultModel = def ? (def.model ?? def.id) : defaultModel;
294
+ return { installed: true, models, default: def ? `codex/${def.model ?? def.id}` : (models[0] ? `codex/${models[0].id}` : null) };
295
+ },
296
+
297
+ async run(req, onEvent) {
298
+ const cwd = String(req.cwd || '');
299
+ if (!cwd || !path.isAbsolute(cwd)) throw new Error('run needs an absolute cwd');
300
+ await fsp.mkdir(cwd, { recursive: true });
301
+ await ensureServer();
302
+ const runId = randomUUID();
303
+ const emit = (event) => { try { onEvent({ runId, event }); } catch { /* renderer gone */ } };
304
+ const state = { runId, threadId: null, turnId: null, text: '', final: null, waiting: false, emit, end: null, done: null,
305
+ // what this conversation already allowed for good (see README: `rule`)
306
+ allowRules: new Set(Array.isArray(req.allowRules) ? req.allowRules.filter((s) => typeof s === 'string' && s.startsWith('codex:')) : []) };
307
+ state.done = new Promise((resolve) => { state.end = (how) => { if (state.ended) return; state.ended = true; resolve(how); }; });
308
+ runs.set(runId, state);
309
+ void (async () => {
310
+ const IDLE = Number(process.env.TD_AGENT_IDLE_MS) || 10 * 60 * 1000;
311
+ let lastEvent = Date.now();
312
+ const origEmit = state.emit;
313
+ state.emit = (event) => { lastEvent = Date.now(); if (event.type === 'question') state.waiting = true; if (event.type === 'question_answered') state.waiting = false; origEmit(event); };
314
+ const watchdog = setInterval(() => {
315
+ if (state.waiting || Date.now() - lastEvent < IDLE) return;
316
+ 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` });
317
+ if (state.threadId && state.turnId) send('turn/interrupt', { threadId: state.threadId, turnId: state.turnId }).catch(() => {});
318
+ setTimeout(() => state.end('idle'), 5000);
319
+ }, 15000);
320
+ try {
321
+ const policy = await policyFor(cwd);
322
+ const model = (req.model && typeof req.model === 'object' && req.model.id ? req.model.id : null) ?? await defaultModelId();
323
+ let thread;
324
+ const resumeId = req.sessionPath ? threadIdOf(req.sessionPath) : null;
325
+ if (req.forkEntryId && resumeId) thread = (await send('thread/fork', { threadId: resumeId, cwd, ...policy, ...(model ? { model } : {}) })).thread;
326
+ else if (resumeId) thread = (await send('thread/resume', { threadId: resumeId, cwd, ...policy, ...(model ? { model } : {}) })).thread;
327
+ else thread = (await send('thread/start', { cwd, ...policy, ...(model ? { model } : {}) })).thread;
328
+ state.threadId = thread.id;
329
+ byThread.set(thread.id, runId);
330
+ state.emit({ type: 'session', sessionId: thread.id, sessionFile: thread.path ?? null, model: model ? { provider: 'codex', id: model, name: model } : null, cwd });
331
+ const before = await snapshotDir(cwd).catch(() => null);
332
+ const input = [{ type: 'text', text: String(req.prompt ?? ''), text_elements: [] }];
333
+ 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}` });
334
+ const effort = req.effort && model ? effortFor(model, req.effort) : null;
335
+ const started = await send('turn/start', { threadId: thread.id, input, ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, 60 * 1000);
336
+ state.turnId = started?.turn?.id ?? null;
337
+ const how = await state.done;
338
+ const ran = await turnEffortOf(thread.path);
339
+ if (ran) state.emit({ type: 'session', sessionId: thread.id, sessionFile: thread.path ?? null, model: model ? { provider: 'codex', id: model, name: model } : null, cwd, effort: ran });
340
+ if (before) {
341
+ const after = await snapshotDir(cwd).catch(() => null);
342
+ 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 }); }
343
+ }
344
+ state.emit({ type: 'run_end', text: state.final ?? state.text, how });
345
+ } catch (e) {
346
+ state.emit({ type: 'run_error', message: e instanceof Error ? e.message : String(e) });
347
+ state.emit({ type: 'run_end', text: state.final ?? state.text, how: 'error' });
348
+ } finally {
349
+ clearInterval(watchdog);
350
+ if (state.threadId) byThread.delete(state.threadId);
351
+ runs.delete(runId);
352
+ }
353
+ })();
354
+ return runId;
355
+ },
356
+
357
+ abort(runId) {
358
+ const r = runs.get(runId);
359
+ if (!r) return false;
360
+ // withdraw any question still open for this run, then interrupt
361
+ for (const [id, ask] of serverAsks) { if (id.startsWith('q-')) { /* the server's own timeout covers it */ } void ask; }
362
+ if (r.threadId && r.turnId) send('turn/interrupt', { threadId: r.threadId, turnId: r.turnId }).catch(() => {});
363
+ return true;
364
+ },
365
+
366
+ answer(runId, requestId, response) {
367
+ const ask = serverAsks.get(String(requestId));
368
+ if (!ask || !runs.has(runId)) return false;
369
+ serverAsks.delete(String(requestId));
370
+ const a = response && typeof response === 'object' ? response : { confirmed: !!response };
371
+ if (a.cancelled) { ask.respond({ confirmed: false, value: '' }, String(requestId)); return true; }
372
+ ask.respond(a, String(requestId));
373
+ return true;
374
+ },
375
+
376
+ shutdown() { clearInterval(retire); try { proc?.kill(); } catch { /* gone */ } proc = null; ready = null; },
377
+ };
378
+ }
379
+
380
+ 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,95 @@
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
+ 'claude-code': (log) => require('./claude.cjs').createClaudeRuntime({ log }),
16
+ };
17
+
18
+ function createAgentsHttp({ workspaceRoot = path.join(os.homedir(), '.thoughtdag', 'workspaces'), log = () => {} } = {}) {
19
+ const runtimes = new Map();
20
+ const owners = new Map(); // runId → runtime name
21
+ const clients = new Set(); // SSE responses
22
+ const runtime = (name = 'pi') => {
23
+ const key = RUNTIME_FACTORIES[name] ? name : 'pi';
24
+ if (!runtimes.has(key)) runtimes.set(key, RUNTIME_FACTORIES[key](log));
25
+ return runtimes.get(key);
26
+ };
27
+ const broadcast = (payload) => {
28
+ const line = `data: ${JSON.stringify(payload)}\n\n`;
29
+ for (const res of clients) { try { res.write(line); } catch { clients.delete(res); } }
30
+ };
31
+ const heartbeat = setInterval(() => { for (const res of clients) { try { res.write(': hb\n\n'); } catch { clients.delete(res); } } }, 20000);
32
+ heartbeat.unref?.();
33
+
34
+ const json = (res, status, body) => {
35
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
36
+ res.end(JSON.stringify(body));
37
+ };
38
+
39
+ /**
40
+ * Handle one request. `sub` is the path after the mount point, starting
41
+ * with `/agents`; `body` is the parsed JSON body (or null). Returns false
42
+ * when the path is not ours.
43
+ */
44
+ async function handle(req, res, sub, body) {
45
+ const url = new URL(req.url ?? '/', 'http://local');
46
+ const p = sub.replace(/\/+$/, '');
47
+ const method = req.method ?? 'GET';
48
+ try {
49
+ if (p === '/agents/available' && method === 'GET') {
50
+ const out = {};
51
+ for (const name of Object.keys(RUNTIME_FACTORIES)) out[name] = await runtime(name).available().catch(() => null);
52
+ return json(res, 200, out), true;
53
+ }
54
+ if (p === '/agents/models' && method === 'GET') {
55
+ try { return json(res, 200, await runtime(url.searchParams.get('runtime') ?? 'pi').models()), true; }
56
+ catch (e) { return json(res, 200, { installed: false, models: [], default: null, error: e instanceof Error ? e.message : String(e) }), true; }
57
+ }
58
+ if (p === '/agents/events' && method === 'GET') {
59
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
60
+ res.write(': connected\n\n');
61
+ clients.add(res);
62
+ req.on('close', () => clients.delete(res));
63
+ return true;
64
+ }
65
+ if (p === '/agents/run' && method === 'POST') {
66
+ const r = body && typeof body === 'object' ? body : {};
67
+ const name = RUNTIME_FACTORIES[r.runtime] ? r.runtime : 'pi';
68
+ const runId = await runtime(name).run(r, broadcast);
69
+ owners.set(runId, name);
70
+ return json(res, 200, { runId }), true;
71
+ }
72
+ if (p === '/agents/abort' && method === 'POST') {
73
+ const id = String(body?.runId ?? '');
74
+ return json(res, 200, { ok: runtime(owners.get(id)).abort(id) }), true;
75
+ }
76
+ if (p === '/agents/answer' && method === 'POST') {
77
+ const id = String(body?.runId ?? '');
78
+ return json(res, 200, { ok: runtime(owners.get(id)).answer(id, String(body?.requestId ?? ''), body?.response) }), true;
79
+ }
80
+ if (p === '/agents/workspace' && method === 'POST') return json(res, 200, { dir: await ops.workspaceFor(workspaceRoot, String(body?.canvasId ?? 'default')) }), true;
81
+ if (p === '/agents/guard' && method === 'POST') return json(res, 200, { ok: await ops.writeGuard(String(body?.cwd ?? ''), body?.config) }), true;
82
+ if (p === '/agents/materials' && method === 'POST') return json(res, 200, await ops.writeMaterials(String(body?.cwd ?? ''), body?.files)), true;
83
+ 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;
84
+ return false;
85
+ } catch (e) {
86
+ json(res, 500, { error: e instanceof Error ? e.message : String(e) });
87
+ return true;
88
+ }
89
+ }
90
+
91
+ const shutdown = () => { clearInterval(heartbeat); for (const r of runtimes.values()) r.shutdown(); for (const res of clients) { try { res.end(); } catch { /* gone */ } } };
92
+ return { handle, shutdown };
93
+ }
94
+
95
+ 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 };
@@ -0,0 +1,81 @@
1
+ // A Pi extension the desktop shell loads into every run it starts
2
+ // (`pi -e <this file>`). One rule: the working directory is the boundary.
3
+ // A tool call that reaches outside it — a read, write or edit by path, a
4
+ // bash command naming a path elsewhere — asks the person first; in rpc
5
+ // mode that question travels to the canvas and comes back as an
6
+ // approval. Denied means the call is blocked with a reason the model
7
+ // sees. Paths the system owns (/usr, /bin, /dev, /tmp…) pass: a command
8
+ // that runs /usr/bin/env is not an excursion.
9
+ //
10
+ // The canvas tunes the rule through <cwd>/.thoughtdag/guard.json, read on
11
+ // every call so a change takes effect mid-run:
12
+ // { "mode": "ask" | "allow", "allow": ["/a/directory", …] }
13
+ // `allow` mode asks nothing; a path under an allowed directory passes.
14
+ import { resolve, sep, dirname, join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { readFileSync, statSync } from 'node:fs';
17
+
18
+ const SYSTEM_PREFIXES = ['/dev/', '/usr/', '/bin/', '/sbin/', '/opt/', '/etc/', '/tmp/', '/private/tmp/', '/private/var/', '/var/', '/System/', '/Library/', '/Applications/', '/proc/'];
19
+ /** the separator between the human message and the structured tail of a confirm */
20
+ const TAIL = '␟';
21
+
22
+ function expand(p, cwd) {
23
+ if (p === '~') return homedir();
24
+ if (p.startsWith('~/')) return resolve(homedir(), p.slice(2));
25
+ return resolve(cwd, p);
26
+ }
27
+
28
+ const inside = (abs, root) => abs === root || abs.startsWith(root + sep);
29
+ const systemOwned = (abs) => SYSTEM_PREFIXES.some((pre) => abs.startsWith(pre));
30
+
31
+ /** Paths a bash command names: absolute ones, ~ ones, and cd targets. */
32
+ function pathsInCommand(command, cwd) {
33
+ const out = new Set();
34
+ const text = String(command ?? '');
35
+ for (const m of text.matchAll(/(?:^|[\s=:'"(])((?:~|\/)[^\s'"`;|&)<>]*)/g)) out.add(expand(m[1], cwd));
36
+ for (const m of text.matchAll(/(?:^|[;&|]\s*|\s)cd\s+([^\s;|&]+)/g)) out.add(expand(m[1].replace(/^['"]|['"]$/g, ''), cwd));
37
+ return [...out];
38
+ }
39
+
40
+ function pathsOf(event, cwd) {
41
+ const input = event.input ?? {};
42
+ switch (event.toolName) {
43
+ case 'bash': return pathsInCommand(input.command, cwd);
44
+ case 'read': case 'write': case 'edit': case 'ls': case 'grep': case 'find':
45
+ return typeof input.path === 'string' && input.path ? [expand(input.path, cwd)] : [];
46
+ default: return [];
47
+ }
48
+ }
49
+
50
+ function config(cwd) {
51
+ try {
52
+ const raw = JSON.parse(readFileSync(join(cwd, '.thoughtdag', 'guard.json'), 'utf8'));
53
+ return { mode: raw?.mode === 'allow' ? 'allow' : 'ask', allow: Array.isArray(raw?.allow) ? raw.allow.filter((x) => typeof x === 'string' && x.startsWith('/')).map((x) => resolve(x)) : [] };
54
+ } catch { return { mode: 'ask', allow: [] }; }
55
+ }
56
+
57
+ /** The directory to offer as "allow this location": the path itself when
58
+ * it is a directory, else its parent; several paths → their common parent. */
59
+ function suggestion(paths) {
60
+ const dirs = paths.map((p) => { try { return statSync(p).isDirectory() ? p : dirname(p); } catch { return dirname(p); } });
61
+ let common = dirs[0].split(sep);
62
+ for (const d of dirs.slice(1)) { const parts = d.split(sep); let i = 0; while (i < common.length && i < parts.length && common[i] === parts[i]) i++; common = common.slice(0, i); }
63
+ const dir = common.join(sep) || sep;
64
+ return dir === sep ? dirs[0] : dir;
65
+ }
66
+
67
+ export default function (pi) {
68
+ pi.on('tool_call', async (event, ctx) => {
69
+ const root = resolve(ctx.cwd);
70
+ const cfg = config(root);
71
+ if (cfg.mode === 'allow') return;
72
+ const outside = pathsOf(event, root).filter((p) => !inside(p, root) && !systemOwned(p) && !cfg.allow.some((a) => inside(p, a)));
73
+ if (outside.length === 0) return;
74
+ const where = outside.slice(0, 3).join(', ') + (outside.length > 3 ? ` (+${outside.length - 3})` : '');
75
+ if (!ctx.hasUI) return { block: true, reason: `outside the working directory (${where}); nobody to ask` };
76
+ const detail = event.toolName === 'bash' ? String(event.input?.command ?? '') : where;
77
+ const tail = TAIL + JSON.stringify({ paths: outside, suggest: suggestion(outside) });
78
+ const ok = await ctx.ui.confirm(`${event.toolName} outside the working directory`, detail + tail);
79
+ if (!ok) return { block: true, reason: `the person did not allow ${event.toolName} outside the working directory (${where})` };
80
+ });
81
+ }