dsh-thoughtdag 0.4.10 → 0.4.12

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 (30) hide show
  1. package/README.md +7 -0
  2. package/dist-app/assets/{canonical-BZIQSZy0.js → canonical-DccXDqkX.js} +2 -2
  3. package/dist-app/assets/{canvas-record-D7Ns1Kpg.js → canvas-record-Dw4tjwhH.js} +1 -1
  4. package/dist-app/assets/{claude-code-session-0K23DXD3.js → claude-code-session-ON4i33o-.js} +1 -1
  5. package/dist-app/assets/{codex-session-Cs0gAA_-.js → codex-session-BWQTXLRK.js} +1 -1
  6. package/dist-app/assets/{dsh-session-Bl9dP5PX.js → dsh-session-DoRNUaQq.js} +1 -1
  7. package/dist-app/assets/{experiment-loop-GwbyJMFI.js → experiment-loop-BuZFR0si.js} +1 -1
  8. package/dist-app/assets/index-2pggDGtC.css +1 -0
  9. package/dist-app/assets/{index-CZpsihFG.js → index-CTQSx_k0.js} +3 -3
  10. package/dist-app/assets/{index-B5j_QAmX.js → index-DD461-fW.js} +1 -1
  11. package/dist-app/assets/{index-DWm1onYm.js → index-DaODh24h.js} +1 -1
  12. package/dist-app/assets/index-DwwxoOcO.js +788 -0
  13. package/dist-app/assets/{live-mirror-DXHCi_Hf.js → live-mirror-D3u92mbO.js} +2 -2
  14. package/dist-app/assets/{pi-session-D-6Qneax.js → pi-session-IbIlOArr.js} +1 -1
  15. package/dist-app/assets/{sensitive-scan-BiyGrjXf.js → sensitive-scan-C6YN1rzZ.js} +1 -1
  16. package/dist-app/assets/{session-handoff-IiyUUNcD.js → session-handoff-Fpl3jes7.js} +2 -2
  17. package/dist-app/assets/{shared-BUQtzATG.js → shared-DCtLTsEN.js} +1 -1
  18. package/dist-app/assets/{turndown-plugin-gfm.cjs-BOHTxH4C.js → turndown-plugin-gfm.cjs-CqvfCrbV.js} +1 -1
  19. package/dist-app/assets/{update-check-BojzO74W.js → update-check-B1kgBEyl.js} +1 -1
  20. package/dist-app/index.html +2 -2
  21. package/lib/client.js +3 -2
  22. package/lib/index.js +59 -11
  23. package/lib/runtime/agents/README.md +8 -6
  24. package/lib/runtime/agents/claude.cjs +303 -0
  25. package/lib/runtime/agents/codex.cjs +93 -15
  26. package/lib/runtime/agents/http.cjs +1 -0
  27. package/lib/runtime/agents/pi.cjs +21 -6
  28. package/package.json +1 -1
  29. package/dist-app/assets/index-BBqrNNdn.js +0 -787
  30. package/dist-app/assets/index-BfIl0Oie.css +0 -1
@@ -0,0 +1,303 @@
1
+ // Claude Code as an agent runtime: one `claude -p` process per turn, spoken
2
+ // to in stream-json both ways. The turn's text and thinking arrive as
3
+ // content-block deltas, tool calls as tool_use blocks with their
4
+ // tool_result, permission prompts as control requests answered on stdin
5
+ // (`--permission-prompt-tool stdio`), the end as a result message. A session
6
+ // continues with `--resume`, branches with `--fork-session`. Claude Code
7
+ // keeps its session files under ~/.claude/projects, its login and its model
8
+ // aliases; the shell only asks. Same event kinds as the other runtimes.
9
+ 'use strict';
10
+
11
+ const { spawn, execFile } = 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 CANDIDATE_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.claude', 'local'), path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.bun', 'bin')];
20
+
21
+ async function executable(p) { try { await fsp.access(p, fs.constants.X_OK); return true; } catch { return false; } }
22
+ async function findClaude() {
23
+ const names = process.platform === 'win32' ? ['claude.cmd', 'claude.exe', 'claude'] : ['claude'];
24
+ const dirs = [...(process.env.PATH || '').split(path.delimiter), ...CANDIDATE_DIRS].filter(Boolean);
25
+ for (const d of dirs) for (const n of names) { const p = path.join(d, n); if (await executable(p)) return p; }
26
+ return null;
27
+ }
28
+ function childEnv() {
29
+ const seen = new Set((process.env.PATH || '').split(path.delimiter).filter(Boolean));
30
+ return { ...process.env, PATH: [...seen, ...CANDIDATE_DIRS.filter((d) => !seen.has(d))].join(path.delimiter) };
31
+ }
32
+
33
+ /** Claude Code has no model list; its aliases resolve to the newest model
34
+ * of each family inside the CLI, so they never go stale. The CLI's own
35
+ * "default" is not offered: which model it means lives in the CLI's settings
36
+ * and can change under the person, so the picker names families only. */
37
+ /** The effort levels this CLI accepts, in its own words, read from its own
38
+ * `--help` (the `--effort <level>` entry lists them in parentheses) — so a
39
+ * CLI that adds a level shows it without a change here. Empty when the
40
+ * CLI has no such flag. Read once per launch. */
41
+ let effortsPromise = null;
42
+ function effortsOf(bin) {
43
+ if (!effortsPromise) effortsPromise = new Promise((resolve) => {
44
+ execFile(bin, ['--help'], { env: childEnv(), timeout: 8000, maxBuffer: 1 << 20 }, (_err, stdout) => {
45
+ const help = String(stdout || '');
46
+ const at = help.indexOf('--effort');
47
+ if (at < 0) return resolve([]);
48
+ const next = help.indexOf('\n --', at + 1);
49
+ const m = help.slice(at, next > 0 ? next : undefined).match(/\(([a-z0-9_, \n-]+)\)/i);
50
+ resolve(m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : []);
51
+ });
52
+ });
53
+ return effortsPromise;
54
+ }
55
+
56
+ const MODELS = [
57
+ { provider: 'claude-code', id: 'fable', name: 'Fable', reasoning: true, vision: true },
58
+ { provider: 'claude-code', id: 'opus', name: 'Opus', reasoning: true, vision: true },
59
+ { provider: 'claude-code', id: 'sonnet', name: 'Sonnet', reasoning: true, vision: true },
60
+ { provider: 'claude-code', id: 'haiku', name: 'Haiku', reasoning: true, vision: true },
61
+ ];
62
+
63
+ /** The effort a turn ran at, as the transcript records it on every
64
+ * assistant entry (`effort`) — the CLI's own setting when the canvas named none. */
65
+ async function turnEffortOf(file) {
66
+ if (!file) return null;
67
+ try {
68
+ const lines = (await fsp.readFile(file, 'utf8')).split('\n');
69
+ for (let i = lines.length - 1; i >= 0; i--) {
70
+ if (!lines[i].includes('"effort"')) continue;
71
+ try { const o = JSON.parse(lines[i]); if (o.type === 'assistant' && typeof o.effort === 'string') return o.effort; } catch { /* torn line */ }
72
+ }
73
+ } catch { /* no file */ }
74
+ return null;
75
+ }
76
+
77
+ /** The CLI's configured effort (`effortLevel` in ~/.claude/settings.json), when set. */
78
+ async function configuredEffort() {
79
+ try { const s = JSON.parse(await fsp.readFile(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8')); return typeof s.effortLevel === 'string' ? s.effortLevel : null; } catch { return null; }
80
+ }
81
+
82
+ /** ~/.claude/projects/<slug>/<sessionId>.jsonl. The slug is the cwd with
83
+ * '/' and '.' turned into '-', but a long path is cut and given a short
84
+ * suffix by Claude Code itself — so the file is found by its id across the
85
+ * project directories when the computed name is not there. */
86
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
87
+ async function sessionFileFor(cwd, sessionId) {
88
+ const guess = path.join(PROJECTS_DIR, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`);
89
+ if (await fsp.access(guess).then(() => true, () => false)) return guess;
90
+ try {
91
+ const dirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
92
+ for (const d of dirs) {
93
+ if (!d.isDirectory()) continue;
94
+ const candidate = path.join(PROJECTS_DIR, d.name, `${sessionId}.jsonl`);
95
+ if (await fsp.access(candidate).then(() => true, () => false)) return candidate;
96
+ }
97
+ } catch { /* no projects yet */ }
98
+ return null;
99
+ }
100
+ // the session id is the file's name; a directory on the way may carry a
101
+ // UUID of its own (a project path that names a session), so take the LAST
102
+ const sessionIdOf = (sessionPath) => { 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); return all && all.length ? all[all.length - 1] : null; };
103
+
104
+ /** The guard file the canvas writes per working directory, as a permission mode. */
105
+ async function permissionModeFor(cwd) {
106
+ try {
107
+ const raw = JSON.parse(await fsp.readFile(path.join(cwd, '.thoughtdag', 'guard.json'), 'utf8'));
108
+ if (raw?.mode === 'allow') return 'bypassPermissions';
109
+ } catch { /* default */ }
110
+ return 'default';
111
+ }
112
+
113
+ function createClaudeRuntime({ log } = {}) {
114
+ const say = log || (() => {});
115
+ const runs = new Map(); // runId → state
116
+ let binPromise = null;
117
+ const bin = () => { if (!binPromise) binPromise = findClaude(); return binPromise; };
118
+
119
+ return {
120
+ available: () => bin(),
121
+
122
+ async models() {
123
+ const b = await bin();
124
+ if (!b) return { installed: false, models: [], default: null };
125
+ const [efforts, defaultEffort] = await Promise.all([effortsOf(b), configuredEffort()]);
126
+ return { installed: true, models: MODELS.map((m) => ({ ...m, efforts, defaultEffort })), default: 'claude/claude-code/opus' };
127
+ },
128
+
129
+ async run(req, onEvent) {
130
+ const cwd = String(req.cwd || '');
131
+ if (!cwd || !path.isAbsolute(cwd)) throw new Error('run needs an absolute cwd');
132
+ await fsp.mkdir(cwd, { recursive: true });
133
+ const b = await bin();
134
+ if (!b) throw new Error('claude is not installed (no `claude` on PATH or in the usual places)');
135
+ const runId = randomUUID();
136
+ const emit = (event) => { try { onEvent({ runId, event }); } catch { /* renderer gone */ } };
137
+ const state = { runId, proc: null, asks: new Map(), text: '', final: null, sessionId: null, waiting: false, ended: false, aborted: false,
138
+ // what this conversation already allowed for good (see README: `rule`)
139
+ allowRules: new Set(Array.isArray(req.allowRules) ? req.allowRules.filter((s) => typeof s === 'string' && s.startsWith('claude:')) : []) };
140
+ runs.set(runId, state);
141
+
142
+ void (async () => {
143
+ const IDLE = Number(process.env.TD_AGENT_IDLE_MS) || 10 * 60 * 1000;
144
+ let lastEvent = Date.now();
145
+ const mark = (event) => { lastEvent = Date.now(); if (event.type === 'question') state.waiting = true; if (event.type === 'question_answered') state.waiting = false; emit(event); };
146
+ let watchdog = null;
147
+ const before = await snapshotDir(cwd).catch(() => null);
148
+ try {
149
+ const mode = await permissionModeFor(cwd);
150
+ const model = req.model && typeof req.model === 'object' && req.model.id && req.model.id !== 'default' ? req.model.id : null;
151
+ const resumeId = req.sessionPath ? sessionIdOf(req.sessionPath) : null;
152
+ const args = ['-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', mode];
153
+ if (mode !== 'bypassPermissions') args.push('--permission-prompt-tool', 'stdio');
154
+ if (model) args.push('--model', model);
155
+ // only a level the CLI itself lists; anything else means its default
156
+ if (req.effort && (await effortsOf(b)).includes(req.effort)) args.push('--effort', req.effort);
157
+ if (resumeId) { args.push('--resume', resumeId); if (req.forkEntryId) args.push('--fork-session'); }
158
+ const proc = spawn(b, args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
159
+ state.proc = proc;
160
+ let buf = '';
161
+ const finished = new Promise((resolve) => {
162
+ const done = (how) => { if (state.ended) return; state.ended = true; resolve(how); };
163
+ proc.stdout.on('data', (d) => {
164
+ buf += d.toString();
165
+ let i;
166
+ while ((i = buf.indexOf('\n')) >= 0) {
167
+ const line = buf.slice(0, i); buf = buf.slice(i + 1);
168
+ if (!line.trim()) continue;
169
+ let m; try { m = JSON.parse(line); } catch { continue; }
170
+ handle(m, done);
171
+ }
172
+ });
173
+ proc.stderr.on('data', (d) => say('[claude stderr] ' + d.toString().trim().slice(0, 400)));
174
+ proc.on('error', (e) => { mark({ type: 'run_error', message: 'spawn error: ' + e.message }); done('error'); });
175
+ proc.on('exit', (code, signal) => { if (!state.ended) { if (!state.aborted && code) mark({ type: 'run_error', message: `claude exited (${signal || code})` }); done(state.aborted ? 'aborted' : (code ? 'error' : 'end')); } });
176
+ });
177
+ watchdog = setInterval(() => {
178
+ if (state.waiting || Date.now() - lastEvent < IDLE) return;
179
+ mark({ type: 'run_error', message: `no activity for ${IDLE >= 60000 ? Math.round(IDLE / 60000) + ' min' : Math.round(IDLE / 1000) + ' s'}; the turn was stopped` });
180
+ state.aborted = true; try { proc.kill(); } catch { /* gone */ }
181
+ }, 15000);
182
+
183
+ const handle = (m, done) => {
184
+ switch (m.type) {
185
+ case 'system':
186
+ if (m.subtype === 'init') {
187
+ state.sessionId = m.session_id;
188
+ // the file appears once the first lines are written; the
189
+ // completion re-emits the session with its real path
190
+ mark({ type: 'session', sessionId: m.session_id, sessionFile: null, model: model ? { provider: 'claude-code', id: model, name: model } : null, cwd });
191
+ } else if (m.subtype === 'permission_denied') {
192
+ mark({ type: 'tool_execution_end', toolCallId: m.tool_use_id ?? '', toolName: m.tool_name ?? 'tool', result: 'permission denied', isError: true });
193
+ }
194
+ break;
195
+ case 'stream_event': {
196
+ const ev = m.event ?? {};
197
+ if (ev.type === 'content_block_delta') {
198
+ const d = ev.delta ?? {};
199
+ if (d.type === 'text_delta' && d.text) { state.text += d.text; mark({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: d.text } }); }
200
+ else if (d.type === 'thinking_delta' && d.thinking) mark({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: d.thinking } });
201
+ }
202
+ break;
203
+ }
204
+ case 'assistant':
205
+ // the alias resolved to a real model inside the CLI; the final session event carries it
206
+ if (!state.resolvedModel && typeof m.message?.model === 'string') state.resolvedModel = m.message.model;
207
+ for (const b of m.message?.content ?? []) if (b.type === 'tool_use') mark({ type: 'tool_execution_start', toolCallId: b.id, toolName: b.name, args: b.input ?? {} });
208
+ break;
209
+ case 'user':
210
+ for (const b of m.message?.content ?? []) if (b.type === 'tool_result') mark({ type: 'tool_execution_end', toolCallId: b.tool_use_id, toolName: '', result: typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? null).slice(0, 4000), isError: !!b.is_error });
211
+ break;
212
+ case 'control_request': {
213
+ const r = m.request ?? {};
214
+ if (r.subtype !== 'can_use_tool') { write(proc, { type: 'control_response', response: { subtype: 'error', request_id: m.request_id, error: 'unsupported request' } }); break; }
215
+ const id = `q-${m.request_id}`;
216
+ const input = r.input ?? {};
217
+ const line = typeof input.command === 'string' ? input.command : (input.file_path ?? input.path ?? JSON.stringify(input).slice(0, 200));
218
+ // the CLI's own "don't ask again" suggestion names what a standing
219
+ // allowance covers; sent back scoped to the session, never to settings
220
+ const suggest = (Array.isArray(r.permission_suggestions) ? r.permission_suggestions : []).find((s) => s && s.type === 'addRules' && Array.isArray(s.rules) && s.rules.length);
221
+ const rule = suggest ? 'claude:' + JSON.stringify(suggest.rules.map((x) => ({ toolName: x.toolName, ...(x.ruleContent ? { ruleContent: x.ruleContent } : {}) }))) : null;
222
+ const allow = (forSession) => write(proc, { type: 'control_response', response: { subtype: 'success', request_id: m.request_id, response: { behavior: 'allow', updatedInput: input, ...(forSession && suggest ? { updatedPermissions: [{ ...suggest, destination: 'session' }] } : {}) } } });
223
+ const title = `${r.tool_name ?? 'tool'} needs approval`;
224
+ if (rule && state.allowRules.has(rule)) {
225
+ allow(true);
226
+ mark({ type: 'question_answered', id, outcome: 'allowed-session', value: null, auto: true, title, message: line, rule });
227
+ break;
228
+ }
229
+ state.asks.set(id, (answer) => {
230
+ const yes = !!answer?.confirmed;
231
+ const forSession = yes && answer?.scope === 'session' && !!rule;
232
+ if (forSession) state.allowRules.add(rule);
233
+ if (yes) allow(forSession); else write(proc, { type: 'control_response', response: { subtype: 'success', request_id: m.request_id, response: { behavior: 'deny', message: 'the person did not allow this step' } } });
234
+ mark({ type: 'question_answered', id, outcome: yes ? (forSession ? 'allowed-session' : 'allowed-once') : 'rejected', value: null, ...(forSession ? { rule } : {}) });
235
+ });
236
+ mark({ type: 'question', kind: 'confirm', id, rule, title, message: [line, r.description].filter(Boolean).join('\n'), options: [], placeholder: null, prefill: null, paths: [], suggest: null });
237
+ break;
238
+ }
239
+ case 'result':
240
+ state.final = typeof m.result === 'string' ? m.result : null;
241
+ if (m.subtype && m.subtype !== 'success') mark({ type: 'run_error', message: String(m.result ?? m.subtype) });
242
+ done(m.subtype === 'success' ? 'end' : 'error');
243
+ break;
244
+ default: break;
245
+ }
246
+ };
247
+
248
+ // the prompt goes in as one user message; stdin stays open for answers
249
+ const content = [{ type: 'text', text: String(req.prompt ?? '') }];
250
+ for (const img of Array.isArray(req.images) ? req.images : []) if (img && typeof img.data === 'string') content.push({ type: 'image', source: { type: 'base64', media_type: img.mimeType, data: img.data } });
251
+ write(proc, { type: 'user', message: { role: 'user', content } });
252
+
253
+ const how = await finished;
254
+ // let the CLI finish writing its transcript: it exits on its own
255
+ // after the result; a kill is only the fallback
256
+ await new Promise((resolve) => { if (proc.exitCode !== null || proc.signalCode) return resolve(); const t = setTimeout(() => { try { proc.kill(); } catch { /* gone */ } resolve(); }, 8000); proc.once('exit', () => { clearTimeout(t); resolve(); }); });
257
+ if (state.sessionId) {
258
+ const file = await sessionFileFor(cwd, state.sessionId);
259
+ const ran = await turnEffortOf(file);
260
+ mark({ type: 'session', effort: ran, sessionId: state.sessionId, sessionFile: file, model: { provider: 'claude-code', id: model ?? 'default', name: model ?? 'default', ...(state.resolvedModel ? { resolved: state.resolvedModel } : {}) }, cwd });
261
+ }
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) mark({ type: 'fs_changes', ...d }); }
265
+ }
266
+ mark({ type: 'run_end', text: state.final ?? state.text, how });
267
+ } catch (e) {
268
+ mark({ type: 'run_error', message: e instanceof Error ? e.message : String(e) });
269
+ mark({ type: 'run_end', text: state.final ?? state.text, how: 'error' });
270
+ } finally {
271
+ if (watchdog) clearInterval(watchdog);
272
+ runs.delete(runId);
273
+ }
274
+ })();
275
+ return runId;
276
+ },
277
+
278
+ abort(runId) {
279
+ const r = runs.get(runId);
280
+ if (!r) return false;
281
+ r.aborted = true;
282
+ for (const [id, respond] of r.asks) { respond({ confirmed: false }); r.asks.delete(id); }
283
+ try { r.proc?.kill(); } catch { /* gone */ }
284
+ return true;
285
+ },
286
+
287
+ answer(runId, requestId, response) {
288
+ const r = runs.get(runId);
289
+ const respond = r?.asks.get(String(requestId));
290
+ if (!respond) return false;
291
+ r.asks.delete(String(requestId));
292
+ const a = response && typeof response === 'object' ? response : { confirmed: !!response };
293
+ respond(a.cancelled ? { confirmed: false } : a);
294
+ return true;
295
+ },
296
+
297
+ shutdown() { for (const r of runs.values()) { try { r.proc?.kill(); } catch { /* gone */ } } },
298
+ };
299
+ }
300
+
301
+ function write(proc, obj) { try { proc.stdin.write(JSON.stringify(obj) + '\n'); return true; } catch { return false; } }
302
+
303
+ module.exports = { createClaudeRuntime, findClaude };
@@ -40,9 +40,11 @@ async function policyFor(cwd) {
40
40
  return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
41
41
  }
42
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
43
45
  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
+ 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;
46
48
  };
47
49
 
48
50
  function createCodexRuntime({ log } = {}) {
@@ -61,12 +63,26 @@ function createCodexRuntime({ log } = {}) {
61
63
  const write = (obj) => { try { proc.stdin.write(JSON.stringify(obj) + '\n'); return true; } catch { return false; } };
62
64
  const send = (method, params, timeoutMs = RESPONSE_MS) => new Promise((resolve, reject) => {
63
65
  if (!proc) return reject(new Error('codex app-server is not running'));
66
+ lastUse = Date.now();
64
67
  const id = nextId++;
65
68
  const timer = setTimeout(() => { pending.delete(id); reject(new Error(`codex did not answer ${method} in ${timeoutMs / 1000}s`)); }, timeoutMs);
66
69
  pending.set(id, (msg) => { clearTimeout(timer); if (msg.error) reject(new Error(msg.error.message || `codex refused ${method}`)); else resolve(msg.result); });
67
70
  if (!write({ jsonrpc: '2.0', id, method, params })) { clearTimeout(timer); pending.delete(id); reject(new Error('codex stdin closed')); }
68
71
  });
69
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
+
70
86
  const die = (reason) => {
71
87
  for (const p of pending.values()) p({ error: { message: reason } });
72
88
  pending.clear();
@@ -98,10 +114,35 @@ function createCodexRuntime({ log } = {}) {
98
114
  return ready;
99
115
  }
100
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
+
101
141
  const runOf = (threadId) => { const id = byThread.get(threadId); return id ? runs.get(id) : undefined; };
102
142
 
103
143
  // ── the server speaks: responses, notifications, and its own requests ──
104
144
  function dispatch(msg) {
145
+ lastUse = Date.now();
105
146
  if (msg.id !== undefined && msg.method === undefined) { const p = pending.get(msg.id); if (p) { pending.delete(msg.id); p(msg); } return; }
106
147
  const params = msg.params ?? {};
107
148
  const run = runOf(params.threadId);
@@ -167,14 +208,37 @@ function createCodexRuntime({ log } = {}) {
167
208
  run.waiting = true;
168
209
  run.emit({ type: 'question', id, ...question });
169
210
  };
170
- const decided = (id, outcome, value) => { run.waiting = false; run.emit({ type: 'question_answered', id, outcome, value: value ?? null }); };
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
+ };
171
224
  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'); });
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
+ }
178
242
  case 'item/permissions/requestApproval':
179
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 },
180
244
  (a, id) => { const yes = !!a?.confirmed; reply({ permissions: yes ? (params.permissions ?? {}) : {}, scope: 'turn' }); decided(id, yes ? 'allowed-once' : 'rejected'); });
@@ -196,14 +260,21 @@ function createCodexRuntime({ log } = {}) {
196
260
  }
197
261
  }
198
262
 
199
- const modelEntry = (m) => ({ provider: 'codex', id: m.model ?? m.id, name: m.displayName ?? m.model ?? m.id, reasoning: true, vision: true });
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; };
200
271
  // the model a run uses when the canvas named none: the server's own
201
272
  // default from model/list, not the config file's — a config can name a
202
273
  // model this CLI version cannot run, and the list only carries runnable ones
203
274
  let defaultModel = null;
204
275
  async function defaultModelId() {
205
276
  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 */ }
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 */ }
207
278
  return defaultModel;
208
279
  }
209
280
 
@@ -215,7 +286,9 @@ function createCodexRuntime({ log } = {}) {
215
286
  if (!b) return { installed: false, models: [], default: null };
216
287
  await ensureServer();
217
288
  const r = await send('model/list', {});
218
- const models = (r?.data ?? []).filter((m) => !m.hidden).map(modelEntry);
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));
219
292
  const def = (r?.data ?? []).find((m) => m.isDefault);
220
293
  defaultModel = def ? (def.model ?? def.id) : defaultModel;
221
294
  return { installed: true, models, default: def ? `codex/${def.model ?? def.id}` : (models[0] ? `codex/${models[0].id}` : null) };
@@ -228,7 +301,9 @@ function createCodexRuntime({ log } = {}) {
228
301
  await ensureServer();
229
302
  const runId = randomUUID();
230
303
  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 };
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:')) : []) };
232
307
  state.done = new Promise((resolve) => { state.end = (how) => { if (state.ended) return; state.ended = true; resolve(how); }; });
233
308
  runs.set(runId, state);
234
309
  void (async () => {
@@ -256,9 +331,12 @@ function createCodexRuntime({ log } = {}) {
256
331
  const before = await snapshotDir(cwd).catch(() => null);
257
332
  const input = [{ type: 'text', text: String(req.prompt ?? ''), text_elements: [] }];
258
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}` });
259
- const started = await send('turn/start', { threadId: thread.id, input, ...(model ? { model } : {}) }, 60 * 1000);
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);
260
336
  state.turnId = started?.turn?.id ?? null;
261
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 });
262
340
  if (before) {
263
341
  const after = await snapshotDir(cwd).catch(() => null);
264
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 }); }
@@ -295,7 +373,7 @@ function createCodexRuntime({ log } = {}) {
295
373
  return true;
296
374
  },
297
375
 
298
- shutdown() { try { proc?.kill(); } catch { /* gone */ } proc = null; ready = null; },
376
+ shutdown() { clearInterval(retire); try { proc?.kill(); } catch { /* gone */ } proc = null; ready = null; },
299
377
  };
300
378
  }
301
379
 
@@ -12,6 +12,7 @@ const terminal = require('../terminal.cjs');
12
12
  const RUNTIME_FACTORIES = {
13
13
  pi: (log) => require('./pi.cjs').createPiRuntime({ log }),
14
14
  codex: (log) => require('./codex.cjs').createCodexRuntime({ log }),
15
+ 'claude-code': (log) => require('./claude.cjs').createClaudeRuntime({ log }),
15
16
  };
16
17
 
17
18
  function createAgentsHttp({ workspaceRoot = path.join(os.homedir(), '.thoughtdag', 'workspaces'), log = () => {} } = {}) {
@@ -13,7 +13,7 @@
13
13
 
14
14
  'use strict';
15
15
 
16
- const { spawn } = require('node:child_process');
16
+ const { spawn, execFile } = require('node:child_process');
17
17
  const path = require('node:path');
18
18
  const fs = require('node:fs');
19
19
  const os = require('node:os');
@@ -21,7 +21,7 @@ const { randomUUID } = require('node:crypto');
21
21
 
22
22
  const fsp = fs.promises;
23
23
  const GUARD = path.join(__dirname, 'pi-guard.mjs');
24
- const IDLE_MS = 10 * 60 * 1000;
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
26
  const CANDIDATE_DIRS = [
27
27
  '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin',
@@ -192,6 +192,18 @@ function createPiRuntime({ log } = {}) {
192
192
  let binPromise = null;
193
193
 
194
194
  const bin = () => { if (!binPromise) binPromise = findPi(); return binPromise; };
195
+ // Pi's thinking levels in its own words, from its own `--help`
196
+ // (`--thinking <level> Set thinking level: off, minimal, …`); read once
197
+ let levelsPromise = null;
198
+ const levelsOf = (b) => {
199
+ if (!levelsPromise) levelsPromise = new Promise((resolve) => {
200
+ execFile(b, ['--help'], { env: childEnv(), timeout: 8000, maxBuffer: 1 << 20 }, (_err, stdout) => {
201
+ const m = String(stdout || '').match(/--thinking[^\n]*?level:\s*([a-z0-9_, -]+)/i);
202
+ resolve(m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : []);
203
+ });
204
+ });
205
+ return levelsPromise;
206
+ };
195
207
 
196
208
  async function processFor(cwd) {
197
209
  const live = procs.get(cwd);
@@ -221,8 +233,9 @@ function createPiRuntime({ log } = {}) {
221
233
  let p = procs.get(key);
222
234
  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); }
223
235
  return p.exclusive(async () => {
224
- const [cat, state] = await Promise.all([p.send({ type: 'get_available_models' }), p.send({ type: 'get_state' })]);
225
- const models = (cat?.models ?? []).map(modelEntry);
236
+ const [cat, state, levels] = await Promise.all([p.send({ type: 'get_available_models' }), p.send({ type: 'get_state' }), levelsOf(b)]);
237
+ const defaultEffort = typeof state?.thinkingLevel === 'string' ? state.thinkingLevel : null;
238
+ const models = (cat?.models ?? []).map((m) => ({ ...modelEntry(m), efforts: m.reasoning ? levels : [], defaultEffort: m.reasoning ? defaultEffort : null }));
226
239
  const cur = state?.model ? `${state.model.provider}/${state.model.id}` : null;
227
240
  return { installed: true, models, default: cur && models.some((m) => `${m.provider}/${m.id}` === cur) ? cur : (models[0] ? `${models[0].provider}/${models[0].id}` : null), thinkingLevel: state?.thinkingLevel ?? null };
228
241
  });
@@ -275,9 +288,11 @@ function createPiRuntime({ log } = {}) {
275
288
  if (req.forkEntryId) await proc.send({ type: 'fork', entryId: String(req.forkEntryId) });
276
289
  else if (!req.sessionPath) await proc.send({ type: 'new_session' });
277
290
  if (req.model && typeof req.model === 'object' && req.model.provider && req.model.id) await proc.send({ type: 'set_model', provider: req.model.provider, modelId: req.model.id });
278
- if (req.thinkingLevel) await proc.send({ type: 'set_thinking_level', level: req.thinkingLevel });
291
+ // only a level Pi itself lists; an explicit thinkingLevel still wins
292
+ const level = req.thinkingLevel || (req.effort && (await levelsOf(await bin())).includes(req.effort) ? req.effort : null);
293
+ if (level) await proc.send({ type: 'set_thinking_level', level });
279
294
  const state = await proc.send({ type: 'get_state' });
280
- emit({ type: 'session', sessionId: state?.sessionId ?? null, sessionFile: state?.sessionFile ?? null, model: state?.model ? { provider: state.model.provider, id: state.model.id, name: state.model.name } : null, cwd });
295
+ emit({ type: 'session', effort: typeof state?.thinkingLevel === 'string' ? state.thinkingLevel : null, sessionId: state?.sessionId ?? null, sessionFile: state?.sessionFile ?? null, model: state?.model ? { provider: state.model.provider, id: state.model.id, name: state.model.name } : null, cwd });
281
296
  const before = await snapshotDir(cwd).catch(() => null);
282
297
  if (aborter.aborted) { ended('aborted'); }
283
298
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-thoughtdag",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "ThoughtDAG for DeepSeek Harness: turn the current DSH session into an editable thought graph on an infinite canvas.",
5
5
  "author": "Xia Chen",
6
6
  "repository": {