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.
- package/dist-app/assets/{canonical-Df0ckIdq.js → canonical-Cw94rYnE.js} +2 -2
- package/dist-app/assets/{canvas-record-CEy9ts1U.js → canvas-record-BYGoWCSi.js} +1 -1
- package/dist-app/assets/{claude-code-session-gxApIV4E.js → claude-code-session-D7_MMzYc.js} +1 -1
- package/dist-app/assets/{codex-session-rjwyINLc.js → codex-session-DVFc0JXs.js} +1 -1
- package/dist-app/assets/{dsh-session-9FrjljDY.js → dsh-session-BsZRbFip.js} +1 -1
- package/dist-app/assets/{experiment-loop-B8B2uvfc.js → experiment-loop-C9Q1W_wR.js} +1 -1
- package/dist-app/assets/http-bridge-mmwHcDuo.js +1 -0
- package/dist-app/assets/index-2pggDGtC.css +1 -0
- package/dist-app/assets/{index-Dbevq-w7.js → index-BEqQ5wV9.js} +1 -1
- package/dist-app/assets/{index-CCSkzpjj.js → index-Dyq9QwYZ.js} +3 -3
- package/dist-app/assets/index-Y8weIZcr.js +788 -0
- package/dist-app/assets/{index-DsWaXdBk.js → index-z0ZTt6U1.js} +1 -1
- package/dist-app/assets/{live-mirror-Bs_UiNg0.js → live-mirror-BlUlIINC.js} +2 -2
- package/dist-app/assets/{pi-session-_RCMhkfD.js → pi-session-wpL6N_gb.js} +1 -1
- package/dist-app/assets/{sensitive-scan-Dm_K56VK.js → sensitive-scan-Kz-yplyP.js} +1 -1
- package/dist-app/assets/{session-handoff-asoyabQy.js → session-handoff-BLHyTTl7.js} +2 -2
- package/dist-app/assets/{shared-BFDd9vhH.js → shared-BoxE2SdQ.js} +1 -1
- package/dist-app/assets/{turndown-plugin-gfm.cjs-DvWuwycM.js → turndown-plugin-gfm.cjs-2uYHu5V7.js} +1 -1
- package/dist-app/assets/{update-check-BhinDEBL.js → update-check-B1wesdxM.js} +1 -1
- package/dist-app/index.html +2 -2
- package/lib/client.js +8 -3
- package/lib/index.js +93 -7
- package/lib/runtime/agents/README.md +26 -0
- package/lib/runtime/agents/claude.cjs +303 -0
- package/lib/runtime/agents/codex.cjs +380 -0
- package/lib/runtime/agents/fs-diff.cjs +44 -0
- package/lib/runtime/agents/http.cjs +95 -0
- package/lib/runtime/agents/ops.cjs +54 -0
- package/lib/runtime/agents/pi-guard.mjs +81 -0
- package/lib/runtime/agents/pi.cjs +358 -0
- package/lib/runtime/sessions-fs.cjs +100 -0
- package/lib/runtime/terminal.cjs +105 -0
- package/package.json +1 -1
- package/dist-app/assets/index-BfIl0Oie.css +0 -1
- package/dist-app/assets/index-J-uxm9B7.js +0 -787
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Pi as an agent runtime for the desktop shell. One `pi --mode rpc` child
|
|
2
|
+
// per working directory, spoken to in JSONL: commands go down stdin with an
|
|
3
|
+
// id and come back as `response` lines; everything else on stdout is a
|
|
4
|
+
// session event (agent_start, message_update, tool_execution_*, agent_end…)
|
|
5
|
+
// which the shell forwards to the renderer untouched, tagged with the run
|
|
6
|
+
// it belongs to. Pi keeps its own session files (~/.pi/agent/sessions), its
|
|
7
|
+
// own keys and model catalog: the shell only asks, never copies.
|
|
8
|
+
//
|
|
9
|
+
// A run is one prompt on one session: fresh (`new_session`), continued
|
|
10
|
+
// (`switch_session` to a file), or branched (`fork` at an entry of the
|
|
11
|
+
// current session). Runs on one process are serialized; a process idle for
|
|
12
|
+
// a while is closed; a process that dies is replaced on the next run.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { spawn, execFile } = require('node:child_process');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const { randomUUID } = require('node:crypto');
|
|
21
|
+
|
|
22
|
+
const fsp = fs.promises;
|
|
23
|
+
const GUARD = path.join(__dirname, 'pi-guard.mjs');
|
|
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
|
+
const RESPONSE_MS = 30 * 1000;
|
|
26
|
+
const CANDIDATE_DIRS = [
|
|
27
|
+
'/opt/homebrew/bin', '/usr/local/bin', '/usr/bin',
|
|
28
|
+
path.join(os.homedir(), '.bun', 'bin'), path.join(os.homedir(), '.npm-global', 'bin'),
|
|
29
|
+
path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.pi', 'bin'),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
async function executable(p) {
|
|
33
|
+
try { await fsp.access(p, fs.constants.X_OK); return true; } catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The pi binary: PATH first (a terminal launch), then the usual homes
|
|
37
|
+
* (a Finder launch carries almost no PATH). */
|
|
38
|
+
async function findPi() {
|
|
39
|
+
const names = process.platform === 'win32' ? ['pi.cmd', 'pi.exe', 'pi'] : ['pi'];
|
|
40
|
+
const dirs = [...(process.env.PATH || '').split(path.delimiter), ...CANDIDATE_DIRS].filter(Boolean);
|
|
41
|
+
for (const d of dirs) for (const n of names) { const p = path.join(d, n); if (await executable(p)) return p; }
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** PATH for the child: the shell's plus the usual homes, so Pi's own bash
|
|
46
|
+
* tool finds git, node and friends even under a Finder launch. */
|
|
47
|
+
function childEnv() {
|
|
48
|
+
const seen = new Set((process.env.PATH || '').split(path.delimiter).filter(Boolean));
|
|
49
|
+
const extra = CANDIDATE_DIRS.filter((d) => !seen.has(d));
|
|
50
|
+
return { ...process.env, PATH: [...seen, ...extra].join(path.delimiter) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class PiProcess {
|
|
54
|
+
constructor(bin, cwd, opts) {
|
|
55
|
+
this.cwd = cwd;
|
|
56
|
+
this.log = opts.log || (() => {});
|
|
57
|
+
this.onExit = opts.onExit || (() => {});
|
|
58
|
+
this.pending = new Map();
|
|
59
|
+
this.nextId = 1;
|
|
60
|
+
this.buf = '';
|
|
61
|
+
this.dead = false;
|
|
62
|
+
this.busy = Promise.resolve();
|
|
63
|
+
this.listener = null; // the run currently receiving events
|
|
64
|
+
this.openDialogs = new Set(); // confirm questions forwarded to the canvas, not yet answered
|
|
65
|
+
this.idleTimer = null;
|
|
66
|
+
// extra extensions for development and tests (colon-separated paths)
|
|
67
|
+
const extra = (process.env.TD_PI_EXTRA_EXTENSIONS || '').split(':').filter(Boolean).flatMap((e) => ['-e', e]);
|
|
68
|
+
const args = ['--mode', 'rpc', ...(opts.noSession ? ['--no-session'] : ['-e', GUARD, ...extra])];
|
|
69
|
+
this.proc = spawn(bin, args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
70
|
+
this.proc.stdout.on('data', (d) => this.feed(d.toString()));
|
|
71
|
+
this.proc.stderr.on('data', (d) => this.log('[pi stderr] ' + d.toString().trim().slice(0, 400)));
|
|
72
|
+
this.proc.on('error', (e) => { this.log('[pi] spawn error: ' + e.message); this.die('spawn error: ' + e.message); });
|
|
73
|
+
this.proc.on('exit', (code, signal) => this.die(`pi exited (${signal || code})`));
|
|
74
|
+
this.touch();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
touch() {
|
|
78
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
79
|
+
this.idleTimer = setTimeout(() => { if (!this.listener) this.close(); }, IDLE_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
feed(chunk) {
|
|
83
|
+
this.buf += chunk;
|
|
84
|
+
let i;
|
|
85
|
+
while ((i = this.buf.indexOf('\n')) >= 0) {
|
|
86
|
+
const line = this.buf.slice(0, i); this.buf = this.buf.slice(i + 1);
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let msg;
|
|
89
|
+
try { msg = JSON.parse(line); } catch { this.log('[pi] non-JSON line: ' + line.slice(0, 120)); continue; }
|
|
90
|
+
this.dispatch(msg);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
dispatch(msg) {
|
|
95
|
+
if (msg.type === 'response') {
|
|
96
|
+
const p = msg.id !== undefined ? this.pending.get(msg.id) : undefined;
|
|
97
|
+
if (p) { this.pending.delete(msg.id); p(msg); }
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (msg.type === 'extension_ui_request') {
|
|
101
|
+
// a question for the person — confirm, select, input, editor — goes to
|
|
102
|
+
// the run as one `question` event; the canvas shows it on the node and
|
|
103
|
+
// answers through `answer`. Fire-and-forget notices (notify, status,
|
|
104
|
+
// widget, title) are not questions and get no answer.
|
|
105
|
+
const method = String(msg.method ?? '');
|
|
106
|
+
const KINDS = { confirm: 'confirm', select: 'select', input: 'input', editor: 'editor' };
|
|
107
|
+
if (KINDS[method] && this.listener) {
|
|
108
|
+
this.openDialogs.add(String(msg.id));
|
|
109
|
+
// the guard appends a structured tail (paths, a directory to offer)
|
|
110
|
+
// after U+241F; the person sees only the text before it
|
|
111
|
+
const raw = String(msg.message ?? '');
|
|
112
|
+
const cut = raw.indexOf('\u241F');
|
|
113
|
+
let extra = {};
|
|
114
|
+
if (cut >= 0) { try { extra = JSON.parse(raw.slice(cut + 1)); } catch { extra = {}; } }
|
|
115
|
+
this.listener({
|
|
116
|
+
type: 'question', kind: KINDS[method], id: msg.id, title: msg.title ?? '', message: cut >= 0 ? raw.slice(0, cut) : raw,
|
|
117
|
+
options: Array.isArray(msg.options) ? msg.options.map(String) : [],
|
|
118
|
+
placeholder: typeof msg.placeholder === 'string' ? msg.placeholder : null,
|
|
119
|
+
prefill: typeof msg.prefill === 'string' ? msg.prefill : null,
|
|
120
|
+
paths: Array.isArray(extra.paths) ? extra.paths : [], suggest: typeof extra.suggest === 'string' ? extra.suggest : null,
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (KINDS[method]) {
|
|
125
|
+
// nobody is listening: the dialog resolves to its default at once
|
|
126
|
+
this.write({ type: 'extension_ui_response', id: msg.id, cancelled: true });
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.listener?.(msg);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
write(obj) {
|
|
134
|
+
try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); return true; } catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Withdraw every question still waiting on the canvas: Pi resolves each
|
|
138
|
+
* with its default at once, so nothing dangles after a stop. */
|
|
139
|
+
withdrawDialogs() {
|
|
140
|
+
for (const id of this.openDialogs) this.write({ type: 'extension_ui_response', id, cancelled: true });
|
|
141
|
+
this.openDialogs.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
send(cmd, timeoutMs = RESPONSE_MS) {
|
|
145
|
+
if (this.dead) return Promise.reject(new Error('pi process is gone'));
|
|
146
|
+
const id = String(this.nextId++);
|
|
147
|
+
this.touch();
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`pi did not answer ${cmd.type} in ${timeoutMs / 1000}s`)); }, timeoutMs);
|
|
150
|
+
this.pending.set(id, (msg) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
if (msg.success === false) reject(new Error(msg.error || `pi refused ${cmd.type}`));
|
|
153
|
+
else resolve(msg.data);
|
|
154
|
+
});
|
|
155
|
+
if (!this.write({ id, ...cmd })) { clearTimeout(timer); this.pending.delete(id); reject(new Error('pi stdin closed')); }
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Runs `fn` when no other run holds this process. */
|
|
160
|
+
exclusive(fn) {
|
|
161
|
+
const turn = this.busy.then(fn, fn);
|
|
162
|
+
this.busy = turn.catch(() => {});
|
|
163
|
+
return turn;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
die(reason) {
|
|
167
|
+
if (this.dead) return;
|
|
168
|
+
this.dead = true;
|
|
169
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
170
|
+
for (const p of this.pending.values()) p({ type: 'response', success: false, error: reason });
|
|
171
|
+
this.pending.clear();
|
|
172
|
+
this.listener?.({ type: 'process_exit', reason });
|
|
173
|
+
this.listener = null;
|
|
174
|
+
this.onExit(this);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
close() {
|
|
178
|
+
if (this.dead) return;
|
|
179
|
+
try { this.proc.kill(); } catch { /* already gone */ }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { snapshotDir, diffSnapshots } = require('./fs-diff.cjs');
|
|
184
|
+
|
|
185
|
+
const textOf = (message) => (Array.isArray(message?.content) ? message.content : [])
|
|
186
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('');
|
|
187
|
+
|
|
188
|
+
function createPiRuntime({ log } = {}) {
|
|
189
|
+
const say = log || (() => {});
|
|
190
|
+
const procs = new Map(); // cwd → PiProcess
|
|
191
|
+
const runs = new Map(); // runId → { proc, abort() }
|
|
192
|
+
let binPromise = null;
|
|
193
|
+
|
|
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
|
+
};
|
|
207
|
+
|
|
208
|
+
async function processFor(cwd) {
|
|
209
|
+
const live = procs.get(cwd);
|
|
210
|
+
if (live && !live.dead) return live;
|
|
211
|
+
const b = await bin();
|
|
212
|
+
if (!b) throw new Error('pi is not installed (no `pi` on PATH or in the usual places)');
|
|
213
|
+
const p = new PiProcess(b, cwd, { log: say, onExit: (dead) => { if (procs.get(cwd) === dead) procs.delete(cwd); } });
|
|
214
|
+
procs.set(cwd, p);
|
|
215
|
+
return p;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const modelEntry = (m) => ({
|
|
219
|
+
provider: m.provider, id: m.id, name: m.name || m.id,
|
|
220
|
+
reasoning: !!m.reasoning, vision: Array.isArray(m.input) && m.input.includes('image'),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
/** Where pi is, or null. */
|
|
225
|
+
available: () => bin(),
|
|
226
|
+
|
|
227
|
+
/** Pi's configured models and its current default, asked of a
|
|
228
|
+
* sessionless process in the home directory. */
|
|
229
|
+
async models() {
|
|
230
|
+
const b = await bin();
|
|
231
|
+
if (!b) return { installed: false, models: [], default: null };
|
|
232
|
+
const key = '\0catalog';
|
|
233
|
+
let p = procs.get(key);
|
|
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); }
|
|
235
|
+
return p.exclusive(async () => {
|
|
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 }));
|
|
239
|
+
const cur = state?.model ? `${state.model.provider}/${state.model.id}` : null;
|
|
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 };
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One prompt on one session. `onEvent` receives Pi's session events as
|
|
246
|
+
* they come, plus the shell's own: `session` (which session this run
|
|
247
|
+
* writes to, before the prompt), `run_end` (with the final text), and
|
|
248
|
+
* `run_error`. Resolves with the runId as soon as the run is accepted.
|
|
249
|
+
*/
|
|
250
|
+
async run(req, onEvent) {
|
|
251
|
+
const cwd = String(req.cwd || '');
|
|
252
|
+
if (!cwd || !path.isAbsolute(cwd)) throw new Error('run needs an absolute cwd');
|
|
253
|
+
await fsp.mkdir(cwd, { recursive: true });
|
|
254
|
+
const runId = randomUUID();
|
|
255
|
+
const proc = await processFor(cwd);
|
|
256
|
+
const emit = (event) => { try { onEvent({ runId, event }); } catch { /* renderer gone */ } };
|
|
257
|
+
const aborter = { aborted: false };
|
|
258
|
+
runs.set(runId, { proc, abort: () => { aborter.aborted = true; proc.withdrawDialogs(); proc.send({ type: 'abort' }).catch(() => {}); } });
|
|
259
|
+
|
|
260
|
+
void proc.exclusive(async () => {
|
|
261
|
+
let text = '';
|
|
262
|
+
let ended;
|
|
263
|
+
const finished = new Promise((resolve) => { ended = resolve; });
|
|
264
|
+
// a turn that produces nothing for a long while — a command waiting
|
|
265
|
+
// on stdin, a stuck process — is stopped, with the reason on the node;
|
|
266
|
+
// a question waiting on the person does not count as silence
|
|
267
|
+
const IDLE = Number(process.env.TD_AGENT_IDLE_MS) || 10 * 60 * 1000;
|
|
268
|
+
let lastEvent = Date.now();
|
|
269
|
+
let waitingOnPerson = false;
|
|
270
|
+
const watchdog = setInterval(() => {
|
|
271
|
+
if (waitingOnPerson || Date.now() - lastEvent < IDLE) return;
|
|
272
|
+
emit({ type: 'run_error', message: `no activity for ${IDLE >= 60000 ? Math.round(IDLE / 60000) + ' min' : Math.round(IDLE / 1000) + ' s'}; the turn was stopped` });
|
|
273
|
+
proc.withdrawDialogs();
|
|
274
|
+
proc.send({ type: 'abort' }).catch(() => {});
|
|
275
|
+
setTimeout(() => ended('idle'), 5000);
|
|
276
|
+
}, 15000);
|
|
277
|
+
proc.listener = (event) => {
|
|
278
|
+
lastEvent = Date.now();
|
|
279
|
+
if (event.type === 'question') waitingOnPerson = true;
|
|
280
|
+
if (event.type === 'question_answered') waitingOnPerson = false;
|
|
281
|
+
if (event.type === 'message_end' && event.message?.role === 'assistant') text = textOf(event.message) || text;
|
|
282
|
+
if (event.type === 'process_exit') { emit({ type: 'run_error', message: event.reason }); ended('exit'); return; }
|
|
283
|
+
emit(event);
|
|
284
|
+
if (event.type === 'agent_end') ended('end');
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
if (req.sessionPath) await proc.send({ type: 'switch_session', sessionPath: String(req.sessionPath) });
|
|
288
|
+
if (req.forkEntryId) await proc.send({ type: 'fork', entryId: String(req.forkEntryId) });
|
|
289
|
+
else if (!req.sessionPath) await proc.send({ type: 'new_session' });
|
|
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 });
|
|
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 });
|
|
294
|
+
const state = await proc.send({ type: 'get_state' });
|
|
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 });
|
|
296
|
+
const before = await snapshotDir(cwd).catch(() => null);
|
|
297
|
+
if (aborter.aborted) { ended('aborted'); }
|
|
298
|
+
else {
|
|
299
|
+
// the prompt's response may follow the whole turn; the end is read from events
|
|
300
|
+
proc.send({ type: 'prompt', message: String(req.prompt ?? ''), ...(Array.isArray(req.images) && req.images.length ? { images: req.images } : {}) }, 24 * 60 * 60 * 1000).catch((e) => { emit({ type: 'run_error', message: e.message }); ended('error'); });
|
|
301
|
+
}
|
|
302
|
+
const how = await finished;
|
|
303
|
+
if (before) {
|
|
304
|
+
const after = await snapshotDir(cwd).catch(() => null);
|
|
305
|
+
if (after) {
|
|
306
|
+
const d = diffSnapshots(before, after);
|
|
307
|
+
if (d.changed.length || d.added.length || d.removed.length || d.truncated) emit({ type: 'fs_changes', ...d });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
emit({ type: 'run_end', text, how });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
emit({ type: 'run_error', message: e instanceof Error ? e.message : String(e) });
|
|
313
|
+
emit({ type: 'run_end', text, how: 'error' });
|
|
314
|
+
} finally {
|
|
315
|
+
clearInterval(watchdog);
|
|
316
|
+
proc.withdrawDialogs();
|
|
317
|
+
proc.listener = null;
|
|
318
|
+
proc.touch();
|
|
319
|
+
runs.delete(runId);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return runId;
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
abort(runId) {
|
|
326
|
+
const r = runs.get(runId);
|
|
327
|
+
if (!r) return false;
|
|
328
|
+
r.abort();
|
|
329
|
+
return true;
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The person's answer to a question of a running turn:
|
|
334
|
+
* { confirmed } for a confirm, { value } for select/input/editor,
|
|
335
|
+
* { cancelled: true } to withdraw (the dialog's default applies).
|
|
336
|
+
*/
|
|
337
|
+
answer(runId, requestId, response) {
|
|
338
|
+
const r = runs.get(runId);
|
|
339
|
+
if (!r) return false;
|
|
340
|
+
const id = String(requestId);
|
|
341
|
+
const a = response && typeof response === 'object' ? response : { confirmed: !!response };
|
|
342
|
+
const wire = a.cancelled ? { type: 'extension_ui_response', id, cancelled: true }
|
|
343
|
+
: typeof a.value === 'string' ? { type: 'extension_ui_response', id, value: a.value }
|
|
344
|
+
: { type: 'extension_ui_response', id, confirmed: !!a.confirmed };
|
|
345
|
+
const ok = r.proc.write(wire);
|
|
346
|
+
r.proc.openDialogs.delete(id);
|
|
347
|
+
if (ok) r.proc.listener?.({ type: 'question_answered', id, outcome: a.cancelled ? 'cancelled' : typeof a.value === 'string' ? 'answered' : (a.confirmed ? 'allowed-once' : 'rejected'), value: typeof a.value === 'string' ? a.value : null });
|
|
348
|
+
return ok;
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
shutdown() {
|
|
352
|
+
for (const p of procs.values()) p.close();
|
|
353
|
+
procs.clear();
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = { createPiRuntime, findPi };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The other agents' session files as the atlas reads them, over HTTP, in
|
|
2
|
+
// plain Node — the same primitives the desktop bridge and the harness
|
|
3
|
+
// host expose: roots, a listing, a head, a whole read, a line-aligned
|
|
4
|
+
// range. `rel` never escapes its root. Used by the local server.
|
|
5
|
+
'use strict';
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const { join, resolve, sep } = require('node:path');
|
|
8
|
+
const { readdir, stat, open, readFile } = require('node:fs/promises');
|
|
9
|
+
|
|
10
|
+
const FILE_ROOTS = {
|
|
11
|
+
'claude-projects': join(os.homedir(), '.claude', 'projects'),
|
|
12
|
+
'codex-sessions': join(os.homedir(), '.codex', 'sessions'),
|
|
13
|
+
'pi-sessions': join(os.homedir(), '.pi', 'agent', 'sessions'),
|
|
14
|
+
};
|
|
15
|
+
const FILE_READ_MAX = 256 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
|
18
|
+
|
|
19
|
+
function fileInRoot(rootKey, rel) {
|
|
20
|
+
const root = FILE_ROOTS[rootKey];
|
|
21
|
+
if (!root) throw new HttpError(404, 'no such root');
|
|
22
|
+
if (typeof rel !== 'string' || !rel) throw new HttpError(400, 'rel required');
|
|
23
|
+
const abs = resolve(root, rel);
|
|
24
|
+
if (abs !== root && !abs.startsWith(root + sep)) throw new HttpError(400, 'rel escapes its root');
|
|
25
|
+
return abs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function listRoot(rootKey) {
|
|
29
|
+
const root = FILE_ROOTS[rootKey];
|
|
30
|
+
if (!root) throw new HttpError(404, 'no such root');
|
|
31
|
+
const out = [];
|
|
32
|
+
const walk = async (dir, depth) => {
|
|
33
|
+
if (depth > 5) return;
|
|
34
|
+
let entries;
|
|
35
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
36
|
+
for (const ent of entries) {
|
|
37
|
+
const p = join(dir, ent.name);
|
|
38
|
+
if (ent.isDirectory()) await walk(p, depth + 1);
|
|
39
|
+
else if (ent.isFile() && ent.name.endsWith('.jsonl')) {
|
|
40
|
+
try { const st = await stat(p); out.push({ rel: p.slice(root.length + 1), size: st.size, mtime: st.mtimeMs }); } catch { /* raced */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
await walk(root, 0);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function headOfFile(abs, bytes) {
|
|
49
|
+
const n = Math.min(Math.max(1024, bytes | 0), 524288);
|
|
50
|
+
const fh = await open(abs, 'r');
|
|
51
|
+
try { const buf = Buffer.alloc(n); const { bytesRead } = await fh.read(buf, 0, n, 0); return buf.subarray(0, bytesRead).toString('utf8'); } finally { await fh.close(); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function rangeOfFile(abs, start, length) {
|
|
55
|
+
const fh = await open(abs, 'r');
|
|
56
|
+
try {
|
|
57
|
+
const from = Math.max(0, Number(start) || 0);
|
|
58
|
+
const want = Math.min(Math.max(65536, Number(length) || 0), 32 * 1024 * 1024);
|
|
59
|
+
const st = await fh.stat();
|
|
60
|
+
if (from >= st.size) return { text: '', nextStart: from, eof: true };
|
|
61
|
+
const buf = Buffer.alloc(Math.min(want, st.size - from));
|
|
62
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, from);
|
|
63
|
+
let slice = buf.subarray(0, bytesRead).toString('utf8');
|
|
64
|
+
const eof = from + bytesRead >= st.size;
|
|
65
|
+
if (!eof) { const cut = slice.lastIndexOf('\n'); if (cut >= 0) slice = slice.slice(0, cut + 1); }
|
|
66
|
+
return { text: slice, nextStart: from + Buffer.byteLength(slice, 'utf8'), eof };
|
|
67
|
+
} finally { await fh.close(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Handle `/roots` and `/roots/<key>/<op>`; returns false when not ours. */
|
|
71
|
+
async function handleSessions(req, res, sub) {
|
|
72
|
+
const url = new URL(req.url ?? '/', 'http://local');
|
|
73
|
+
const p = sub.replace(/\/+$/, '');
|
|
74
|
+
const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); return true; };
|
|
75
|
+
const text = (type, body) => { res.writeHead(200, { 'content-type': type }); res.end(body); return true; };
|
|
76
|
+
try {
|
|
77
|
+
if (p === '/roots' && req.method === 'GET') {
|
|
78
|
+
const roots = [];
|
|
79
|
+
for (const [key, dir] of Object.entries(FILE_ROOTS)) roots.push({ key, path: dir, builtin: true, exists: await stat(dir).then((st) => st.isDirectory()).catch(() => false) });
|
|
80
|
+
return json(200, { roots });
|
|
81
|
+
}
|
|
82
|
+
const m = /^\/roots\/([a-z-]+)\/(list|head|read|range)$/.exec(p);
|
|
83
|
+
if (!m || req.method !== 'GET') return false;
|
|
84
|
+
const key = m[1];
|
|
85
|
+
if (m[2] === 'list') return json(200, { files: await listRoot(key) });
|
|
86
|
+
const abs = fileInRoot(key, url.searchParams.get('rel') ?? '');
|
|
87
|
+
if (m[2] === 'head') return text('text/plain; charset=utf-8', await headOfFile(abs, Number(url.searchParams.get('bytes')) || 16384).catch(() => ''));
|
|
88
|
+
if (m[2] === 'read') {
|
|
89
|
+
const st = await stat(abs).catch(() => null);
|
|
90
|
+
if (!st) return json(404, { error: 'no such file' });
|
|
91
|
+
if (st.size > FILE_READ_MAX) return json(413, { error: 'file too large to read whole; use range' });
|
|
92
|
+
return text('application/x-ndjson; charset=utf-8', await readFile(abs));
|
|
93
|
+
}
|
|
94
|
+
return json(200, await rangeOfFile(abs, url.searchParams.get('start'), url.searchParams.get('length')));
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return json(e instanceof HttpError ? e.status : 500, { error: e instanceof Error ? e.message : String(e) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { FILE_ROOTS, fileInRoot, listRoot, headOfFile, rangeOfFile, handleSessions };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Opening a runner's session in the person's terminal, in plain Node: the
|
|
2
|
+
// command each runner resumes with, the terminals we know how to launch on
|
|
3
|
+
// each platform, and one call that picks the first available. The desktop
|
|
4
|
+
// shell adds its own preferences on top; a host without preferences takes
|
|
5
|
+
// the first terminal that exists.
|
|
6
|
+
'use strict';
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const fsp = require('node:fs/promises');
|
|
10
|
+
const { spawn } = require('node:child_process');
|
|
11
|
+
|
|
12
|
+
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
const fileExists = (p) => fsp.access(p).then(() => true, () => false);
|
|
14
|
+
const dirExists = async (p) => { try { return (await fsp.stat(p)).isDirectory(); } catch { return false; } };
|
|
15
|
+
|
|
16
|
+
/** The command that resumes a session in each runner's own CLI. */
|
|
17
|
+
const PROGRAMS = {
|
|
18
|
+
'claude-code': (id) => `claude --resume ${shq(id)}`,
|
|
19
|
+
codex: (id) => `codex resume ${shq(id)}`,
|
|
20
|
+
pi: (id) => `pi --session ${shq(id)}`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const run = (bin, args) => new Promise((resolve) => {
|
|
24
|
+
const c = spawn(bin, args, { stdio: 'ignore', detached: process.platform === 'linux' });
|
|
25
|
+
c.on('error', () => resolve(false));
|
|
26
|
+
c.on('exit', (code) => resolve(code === 0));
|
|
27
|
+
if (process.platform === 'linux') c.unref();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function binOnPath(name) {
|
|
31
|
+
for (const d of (process.env.PATH || '').split(path.delimiter)) {
|
|
32
|
+
if (d && await fileExists(path.join(d, name))) return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// macOS: a .command file runs in the user's default shell (right PATH,
|
|
38
|
+
// zero automation permissions) inside any terminal app that accepts it.
|
|
39
|
+
async function launchCommandFile(appPath, cwd, program) {
|
|
40
|
+
const file = path.join(os.tmpdir(), `thoughtdag-resume-${Date.now()}.command`);
|
|
41
|
+
try {
|
|
42
|
+
await fsp.writeFile(file, `#!/bin/zsh\nrm -- ${shq(file)}\ncd ${shq(cwd)}\n${program}\n`, { mode: 0o755 });
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
return run('open', ['-a', appPath, file]);
|
|
45
|
+
}
|
|
46
|
+
const launchOpenArgs = (appPath, argv) => run('open', ['-na', appPath, '--args', ...argv]);
|
|
47
|
+
const posix = (cwd, program) => ['-lc', `cd ${shq(cwd)} && ${program}`];
|
|
48
|
+
|
|
49
|
+
/** The terminals this platform can launch, in preference order. */
|
|
50
|
+
function buildTerminalRegistry(customTerminals = []) {
|
|
51
|
+
if (process.platform === 'darwin') {
|
|
52
|
+
const appEntry = (id, name, appPath, launch) => ({ id, name, probe: () => dirExists(appPath), launch });
|
|
53
|
+
return [
|
|
54
|
+
appEntry('terminal', 'Terminal', '/System/Applications/Utilities/Terminal.app', (cwd, p) => launchCommandFile('/System/Applications/Utilities/Terminal.app', cwd, p)),
|
|
55
|
+
appEntry('iterm', 'iTerm2', '/Applications/iTerm.app', (cwd, p) => launchCommandFile('/Applications/iTerm.app', cwd, p)),
|
|
56
|
+
appEntry('ghostty', 'Ghostty', '/Applications/Ghostty.app', (cwd, p) => launchOpenArgs('/Applications/Ghostty.app', ['-e', 'zsh', ...posix(cwd, p)])),
|
|
57
|
+
appEntry('warp', 'Warp', '/Applications/Warp.app', (cwd, p) => launchCommandFile('/Applications/Warp.app', cwd, p)),
|
|
58
|
+
appEntry('alacritty', 'Alacritty', '/Applications/Alacritty.app', (cwd, p) => launchOpenArgs('/Applications/Alacritty.app', ['-e', 'zsh', ...posix(cwd, p)])),
|
|
59
|
+
appEntry('kitty', 'kitty', '/Applications/kitty.app', (cwd, p) => launchOpenArgs('/Applications/kitty.app', ['zsh', ...posix(cwd, p)])),
|
|
60
|
+
appEntry('wezterm', 'WezTerm', '/Applications/WezTerm.app', (cwd, p) => launchOpenArgs('/Applications/WezTerm.app', ['start', '--', 'zsh', ...posix(cwd, p)])),
|
|
61
|
+
// user-picked terminal apps ride the .command road — the one macOS
|
|
62
|
+
// mechanism that needs nothing from the app but "opens shell scripts"
|
|
63
|
+
...customTerminals.map((c) => ({ id: c.id, name: c.name, custom: true, probe: () => dirExists(c.app), launch: (cwd, p) => launchCommandFile(c.app, cwd, p) })),
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
if (process.platform === 'win32') {
|
|
67
|
+
const wtPath = path.join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'WindowsApps', 'wt.exe');
|
|
68
|
+
return [
|
|
69
|
+
{ id: 'wt', name: 'Windows Terminal', probe: async () => (await fileExists(wtPath)) || binOnPath('wt.exe'), launch: (cwd, p) => run('wt.exe', ['-d', cwd, 'cmd', '/k', p]) },
|
|
70
|
+
{ id: 'powershell', name: 'PowerShell', probe: async () => true, launch: (cwd, p) => run('cmd', ['/c', 'start', '', 'powershell', '-NoExit', '-Command', `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; ${p}`]) },
|
|
71
|
+
{ id: 'cmd', name: 'Command Prompt', probe: async () => true, launch: (cwd, p) => run('cmd', ['/c', 'start', '', 'cmd', '/k', `cd /d "${cwd}" && ${p}`]) },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
// linux: binaries on PATH, each with its documented cwd/exec flags
|
|
75
|
+
const bin = (id, name, argv) => ({ id, name, probe: () => binOnPath(id), launch: (cwd, p) => run(id, argv(cwd, p)) });
|
|
76
|
+
return [
|
|
77
|
+
bin('gnome-terminal', 'GNOME Terminal', (cwd, p) => ['--working-directory', cwd, '--', 'bash', ...posix(cwd, p)]),
|
|
78
|
+
bin('konsole', 'Konsole', (cwd, p) => ['--workdir', cwd, '-e', 'bash', ...posix(cwd, p)]),
|
|
79
|
+
bin('xfce4-terminal', 'Xfce Terminal', (cwd, p) => [`--working-directory=${cwd}`, '-x', 'bash', ...posix(cwd, p)]),
|
|
80
|
+
bin('ghostty', 'Ghostty', (cwd, p) => ['-e', 'bash', ...posix(cwd, p)]),
|
|
81
|
+
bin('alacritty', 'Alacritty', (cwd, p) => ['--working-directory', cwd, '-e', 'bash', ...posix(cwd, p)]),
|
|
82
|
+
bin('kitty', 'kitty', (cwd, p) => ['--directory', cwd, 'bash', ...posix(cwd, p)]),
|
|
83
|
+
bin('wezterm', 'WezTerm', (cwd, p) => ['start', '--cwd', cwd, '--', 'bash', ...posix(cwd, p)]),
|
|
84
|
+
bin('x-terminal-emulator', 'System terminal', (cwd, p) => ['-e', `bash -lc ${shq(`cd ${shq(cwd)} && ${p}`)}`]),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open a runner's session in a terminal: the preferred one, else the first
|
|
89
|
+
* that exists. Returns what the atlas expects. */
|
|
90
|
+
async function openInTerminal(runner, cwd, sessionId, { preferred = '', customTerminals = [] } = {}) {
|
|
91
|
+
const buildProgram = PROGRAMS[runner];
|
|
92
|
+
if (!buildProgram || !/^[\w.-]{4,}$/.test(String(sessionId))) return { opened: false, via: '', command: '' };
|
|
93
|
+
const dir = typeof cwd === 'string' && path.isAbsolute(cwd) && await dirExists(cwd) ? cwd : os.homedir();
|
|
94
|
+
const program = buildProgram(sessionId);
|
|
95
|
+
const command = `cd ${shq(dir)} && ${program}`;
|
|
96
|
+
const registry = buildTerminalRegistry(customTerminals);
|
|
97
|
+
const ordered = [...registry.filter((t) => t.id === preferred), ...registry.filter((t) => t.id !== preferred)];
|
|
98
|
+
for (const t of ordered) {
|
|
99
|
+
if (!(await t.probe().catch(() => false))) continue;
|
|
100
|
+
if (await t.launch(dir, program)) return { opened: true, via: 'terminal', command, terminal: t.id };
|
|
101
|
+
}
|
|
102
|
+
return { opened: false, via: '', command };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { PROGRAMS, shq, run, binOnPath, launchCommandFile, launchOpenArgs, posix, buildTerminalRegistry, openInTerminal, dirExists, fileExists };
|
package/package.json
CHANGED