agentp 2.0.1 → 2.1.0
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/CONTRIBUTING.md +7 -5
- package/README.md +97 -23
- package/bin/agentp +3 -6
- package/bin/ocmux +564 -244
- package/bin/tgagentp +121 -130
- package/docs/specification.md +2 -1
- package/docs/specification_v2.md +188 -624
- package/lib/ocmux.js +26 -289
- package/lib/opencode.js +186 -43
- package/lib/tui-cmd.js +12 -5
- package/lib/tui-registry.js +313 -0
- package/package.json +1 -1
package/lib/tui-cmd.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
const child_process = require('child_process')
|
|
2
2
|
const { listenForFinalAnswer } = require('./opencode.js')
|
|
3
3
|
|
|
4
|
-
async function executeTuiCommand(
|
|
4
|
+
async function executeTuiCommand(paneTarget, server, command) {
|
|
5
5
|
const commandWithSpace = command + ' '
|
|
6
6
|
const cancelRef = { current: null }
|
|
7
|
+
const paneId = typeof paneTarget === 'string' ? paneTarget : paneTarget.pane
|
|
8
|
+
const socketArgs = typeof paneTarget === 'object' && paneTarget.socket
|
|
9
|
+
? ['-S', paneTarget.socket]
|
|
10
|
+
: []
|
|
7
11
|
|
|
8
12
|
const answerPromise = listenForFinalAnswer(server, null, cancelRef)
|
|
9
13
|
|
|
10
|
-
child_process.spawnSync('tmux', ['send-keys', '-t', paneId, 'C-u'])
|
|
11
|
-
child_process.spawnSync('tmux', ['send-keys', '-t', paneId, '-l', commandWithSpace])
|
|
12
|
-
child_process.spawnSync('tmux', ['send-keys', '-t', paneId, 'Enter'])
|
|
14
|
+
child_process.spawnSync('tmux', [...socketArgs, 'send-keys', '-t', paneId, 'C-u'])
|
|
15
|
+
child_process.spawnSync('tmux', [...socketArgs, 'send-keys', '-t', paneId, '-l', commandWithSpace])
|
|
16
|
+
child_process.spawnSync('tmux', [...socketArgs, 'send-keys', '-t', paneId, 'Enter'])
|
|
13
17
|
|
|
14
18
|
const timeoutMs = 15000
|
|
19
|
+
let timeout
|
|
15
20
|
const timeoutPromise = new Promise((_, reject) => {
|
|
16
|
-
setTimeout(() => reject(new Error('timeout')), timeoutMs)
|
|
21
|
+
timeout = setTimeout(() => reject(new Error('timeout')), timeoutMs)
|
|
17
22
|
})
|
|
18
23
|
|
|
19
24
|
try {
|
|
@@ -24,6 +29,8 @@ async function executeTuiCommand(paneId, server, command) {
|
|
|
24
29
|
cancelRef.current = null
|
|
25
30
|
}
|
|
26
31
|
return null
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timeout)
|
|
27
34
|
}
|
|
28
35
|
}
|
|
29
36
|
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Runtime registry for user-placed OpenCode TUI panes. This is deliberately
|
|
4
|
+
// separate from `.ocmux.json`: pane IDs, tmux sockets and wrapper PIDs are
|
|
5
|
+
// ephemeral machine state, while project files hold durable routing state.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const child_process = require('child_process');
|
|
12
|
+
|
|
13
|
+
const REGISTRY_VERSION = 1;
|
|
14
|
+
const PANE_ID_OPTION = '@ocmux_tui_id';
|
|
15
|
+
const PANE_TOKEN_OPTION = '@ocmux_tui_token';
|
|
16
|
+
|
|
17
|
+
function runtimeDir() {
|
|
18
|
+
if (process.env.OCMUX_RUNTIME_DIR) return path.resolve(process.env.OCMUX_RUNTIME_DIR);
|
|
19
|
+
if (process.env.XDG_RUNTIME_DIR) return path.join(process.env.XDG_RUNTIME_DIR, 'agentp');
|
|
20
|
+
return path.join(os.tmpdir(), `agentp-${process.getuid ? process.getuid() : 'user'}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function registryPath() {
|
|
24
|
+
return path.join(runtimeDir(), 'ocmux-tuis.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emptyRegistry() {
|
|
28
|
+
return { version: REGISTRY_VERSION, instances: {}, projects: {}, shared: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRegistry(value) {
|
|
32
|
+
const input = value && typeof value === 'object' ? value : {};
|
|
33
|
+
return {
|
|
34
|
+
version: REGISTRY_VERSION,
|
|
35
|
+
instances: input.instances && typeof input.instances === 'object' && !Array.isArray(input.instances)
|
|
36
|
+
? input.instances : {},
|
|
37
|
+
projects: input.projects && typeof input.projects === 'object' && !Array.isArray(input.projects)
|
|
38
|
+
? input.projects : {},
|
|
39
|
+
shared: typeof input.shared === 'string' ? input.shared : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readRegistry() {
|
|
44
|
+
try {
|
|
45
|
+
return normalizeRegistry(JSON.parse(fs.readFileSync(registryPath(), 'utf8')));
|
|
46
|
+
} catch {
|
|
47
|
+
return emptyRegistry();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureRuntimeDir() {
|
|
52
|
+
fs.mkdirSync(runtimeDir(), { recursive: true, mode: 0o700 });
|
|
53
|
+
try { fs.chmodSync(runtimeDir(), 0o700); } catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeRegistry(registry) {
|
|
57
|
+
ensureRuntimeDir();
|
|
58
|
+
const file = registryPath();
|
|
59
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(normalizeRegistry(registry)) + '\n', { mode: 0o600 });
|
|
61
|
+
try { fs.chmodSync(tmp, 0o600); } catch {}
|
|
62
|
+
fs.renameSync(tmp, file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sleepMs(ms) {
|
|
66
|
+
// Atomics.wait gives us a dependency-free synchronous sleep for the very
|
|
67
|
+
// short critical section around registry updates.
|
|
68
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withRegistryLock(fn) {
|
|
72
|
+
ensureRuntimeDir();
|
|
73
|
+
const lock = registryPath() + '.lock';
|
|
74
|
+
let fd = null;
|
|
75
|
+
for (let i = 0; i < 50; i++) {
|
|
76
|
+
try {
|
|
77
|
+
fd = fs.openSync(lock, 'wx', 0o600);
|
|
78
|
+
break;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (!err || err.code !== 'EEXIST') throw err;
|
|
81
|
+
try {
|
|
82
|
+
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
83
|
+
if (age > 10000) fs.unlinkSync(lock);
|
|
84
|
+
} catch {}
|
|
85
|
+
sleepMs(20);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (fd === null) throw new Error('timed out waiting for the TUI registry lock');
|
|
89
|
+
try {
|
|
90
|
+
const registry = readRegistry();
|
|
91
|
+
const result = fn(registry);
|
|
92
|
+
writeRegistry(registry);
|
|
93
|
+
return result;
|
|
94
|
+
} finally {
|
|
95
|
+
try { fs.closeSync(fd); } catch {}
|
|
96
|
+
try { fs.unlinkSync(lock); } catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseTmuxEnvironment(env = process.env) {
|
|
101
|
+
const pane = env.TMUX_PANE;
|
|
102
|
+
const raw = env.TMUX;
|
|
103
|
+
if (!pane || !/^%\d+$/.test(pane) || !raw) return null;
|
|
104
|
+
const socket = raw.split(',')[0];
|
|
105
|
+
if (!socket) return null;
|
|
106
|
+
return { socket, pane };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tmux(instance, args) {
|
|
110
|
+
const prefix = instance && instance.socket ? ['-S', instance.socket] : [];
|
|
111
|
+
return child_process.spawnSync('tmux', [...prefix, ...args], {
|
|
112
|
+
encoding: 'utf8',
|
|
113
|
+
maxBuffer: 1024 * 1024,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function paneMetadata(instance) {
|
|
118
|
+
if (!instance || !instance.socket || !instance.pane) return null;
|
|
119
|
+
const result = tmux(instance, [
|
|
120
|
+
'display-message', '-p', '-t', instance.pane,
|
|
121
|
+
`#{pane_dead}\t#{${PANE_ID_OPTION}}\t#{${PANE_TOKEN_OPTION}}\t#{pane_pid}`,
|
|
122
|
+
]);
|
|
123
|
+
if (result.status !== 0) return null;
|
|
124
|
+
const [dead, id, token, pid] = result.stdout.trim().split('\t');
|
|
125
|
+
return { dead: dead === '1', id, token, pid: Number(pid) || null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isLive(instance) {
|
|
129
|
+
const metadata = paneMetadata(instance);
|
|
130
|
+
return !!(metadata && !metadata.dead
|
|
131
|
+
&& metadata.id === instance.id
|
|
132
|
+
&& metadata.token === instance.token);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function markPane(instance) {
|
|
136
|
+
const first = tmux(instance, ['set-option', '-p', '-t', instance.pane, PANE_ID_OPTION, instance.id]);
|
|
137
|
+
if (first.status !== 0) throw new Error(`failed to mark tmux pane ${instance.pane}`);
|
|
138
|
+
const second = tmux(instance, ['set-option', '-p', '-t', instance.pane, PANE_TOKEN_OPTION, instance.token]);
|
|
139
|
+
if (second.status !== 0) throw new Error(`failed to mark tmux pane ${instance.pane}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearPane(instance) {
|
|
143
|
+
if (!isLive(instance)) return;
|
|
144
|
+
tmux(instance, ['set-option', '-pu', '-t', instance.pane, PANE_ID_OPTION]);
|
|
145
|
+
tmux(instance, ['set-option', '-pu', '-t', instance.pane, PANE_TOKEN_OPTION]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function randomID(prefix) {
|
|
149
|
+
return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function removeInstance(registry, id) {
|
|
153
|
+
if (!id || !registry.instances[id]) return null;
|
|
154
|
+
const removed = registry.instances[id];
|
|
155
|
+
delete registry.instances[id];
|
|
156
|
+
for (const [directory, value] of Object.entries(registry.projects)) {
|
|
157
|
+
if (value === id) delete registry.projects[directory];
|
|
158
|
+
}
|
|
159
|
+
if (registry.shared === id) registry.shared = null;
|
|
160
|
+
return removed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function register({ socket, pane, shared, directory, server, session, pid }) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const instance = {
|
|
166
|
+
id: randomID('tui'),
|
|
167
|
+
token: randomID('token'),
|
|
168
|
+
socket,
|
|
169
|
+
pane,
|
|
170
|
+
shared: !!shared,
|
|
171
|
+
directory: directory ? path.resolve(directory) : null,
|
|
172
|
+
server: server || null,
|
|
173
|
+
session: session || null,
|
|
174
|
+
pid: Number(pid) || process.pid,
|
|
175
|
+
registeredAt: now,
|
|
176
|
+
updatedAt: now,
|
|
177
|
+
};
|
|
178
|
+
const displaced = withRegistryLock((registry) => {
|
|
179
|
+
const oldID = shared ? registry.shared : registry.projects[instance.directory];
|
|
180
|
+
const old = oldID ? registry.instances[oldID] : null;
|
|
181
|
+
// Verify and interrupt the old foreground TUI while its token still owns
|
|
182
|
+
// its pane. C-c preserves the pane and avoids treating a reusable PID as
|
|
183
|
+
// durable identity.
|
|
184
|
+
// Marking the replacement first would make that verification impossible.
|
|
185
|
+
const samePane = old && old.socket === instance.socket && old.pane === instance.pane;
|
|
186
|
+
if (old && !samePane && isLive(old)) tmux(old, ['send-keys', '-t', old.pane, 'C-c']);
|
|
187
|
+
markPane(instance);
|
|
188
|
+
if (oldID) removeInstance(registry, oldID);
|
|
189
|
+
// A pane can change between dedicated and shared mode. Remove its prior
|
|
190
|
+
// assignment so one physical pane never occupies two registry slots.
|
|
191
|
+
for (const [id, current] of Object.entries(registry.instances)) {
|
|
192
|
+
if (current.socket === instance.socket && current.pane === instance.pane) {
|
|
193
|
+
removeInstance(registry, id);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
registry.instances[instance.id] = instance;
|
|
197
|
+
if (shared) registry.shared = instance.id;
|
|
198
|
+
else registry.projects[instance.directory] = instance.id;
|
|
199
|
+
return old;
|
|
200
|
+
});
|
|
201
|
+
return { instance, displaced };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function unregister(id, token) {
|
|
205
|
+
let removed = null;
|
|
206
|
+
withRegistryLock((registry) => {
|
|
207
|
+
const current = registry.instances[id];
|
|
208
|
+
if (!current || current.token !== token) return;
|
|
209
|
+
removed = removeInstance(registry, id);
|
|
210
|
+
});
|
|
211
|
+
if (removed) clearPane(removed);
|
|
212
|
+
return !!removed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function updateInstance(id, token, patch) {
|
|
216
|
+
let updated = null;
|
|
217
|
+
withRegistryLock((registry) => {
|
|
218
|
+
const current = registry.instances[id];
|
|
219
|
+
if (!current || current.token !== token) return;
|
|
220
|
+
Object.assign(current, patch, { updatedAt: Date.now() });
|
|
221
|
+
updated = { ...current };
|
|
222
|
+
});
|
|
223
|
+
return updated;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pruneDead() {
|
|
227
|
+
const dead = [];
|
|
228
|
+
withRegistryLock((registry) => {
|
|
229
|
+
for (const [id, instance] of Object.entries(registry.instances)) {
|
|
230
|
+
if (!isLive(instance)) {
|
|
231
|
+
dead.push(instance);
|
|
232
|
+
removeInstance(registry, id);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return dead;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolve(directory) {
|
|
240
|
+
const canonical = directory ? path.resolve(directory) : null;
|
|
241
|
+
const registry = readRegistry();
|
|
242
|
+
const projectID = canonical ? registry.projects[canonical] : null;
|
|
243
|
+
const project = projectID ? registry.instances[projectID] : null;
|
|
244
|
+
if (project && isLive(project)) return { instance: project, kind: 'project' };
|
|
245
|
+
const shared = registry.shared ? registry.instances[registry.shared] : null;
|
|
246
|
+
if (shared && isLive(shared)) return { instance: shared, kind: 'shared' };
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function dedicated(directory) {
|
|
251
|
+
const canonical = directory ? path.resolve(directory) : null;
|
|
252
|
+
if (!canonical) return null;
|
|
253
|
+
pruneDead();
|
|
254
|
+
const registry = readRegistry();
|
|
255
|
+
return registry.projects[canonical] ? registry.instances[registry.projects[canonical]] || null : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function shared() {
|
|
259
|
+
pruneDead();
|
|
260
|
+
const registry = readRegistry();
|
|
261
|
+
return registry.shared ? registry.instances[registry.shared] || null : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function shellQuote(value) {
|
|
265
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function respawn(instance, { executable, directory, server, session }) {
|
|
269
|
+
if (!isLive(instance)) return { ok: false, error: 'registered TUI pane is no longer available' };
|
|
270
|
+
const args = [shellQuote(executable), 'tui'];
|
|
271
|
+
if (instance.shared) args.push('--shared');
|
|
272
|
+
args.push('--server', shellQuote(server), '--session-id', shellQuote(session), '--', shellQuote(directory));
|
|
273
|
+
const result = tmux(instance, [
|
|
274
|
+
'respawn-pane', '-k', '-t', instance.pane, '-c', directory, args.join(' '),
|
|
275
|
+
]);
|
|
276
|
+
if (result.status !== 0) {
|
|
277
|
+
return { ok: false, error: (result.stderr || '').trim() || `failed to respawn ${instance.pane}` };
|
|
278
|
+
}
|
|
279
|
+
return { ok: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function list() {
|
|
283
|
+
pruneDead();
|
|
284
|
+
const registry = readRegistry();
|
|
285
|
+
return Object.values(registry.instances);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
REGISTRY_VERSION,
|
|
290
|
+
PANE_ID_OPTION,
|
|
291
|
+
PANE_TOKEN_OPTION,
|
|
292
|
+
runtimeDir,
|
|
293
|
+
registryPath,
|
|
294
|
+
emptyRegistry,
|
|
295
|
+
normalizeRegistry,
|
|
296
|
+
readRegistry,
|
|
297
|
+
writeRegistry,
|
|
298
|
+
parseTmuxEnvironment,
|
|
299
|
+
paneMetadata,
|
|
300
|
+
isLive,
|
|
301
|
+
markPane,
|
|
302
|
+
clearPane,
|
|
303
|
+
register,
|
|
304
|
+
unregister,
|
|
305
|
+
updateInstance,
|
|
306
|
+
pruneDead,
|
|
307
|
+
resolve,
|
|
308
|
+
dedicated,
|
|
309
|
+
shared,
|
|
310
|
+
respawn,
|
|
311
|
+
list,
|
|
312
|
+
shellQuote,
|
|
313
|
+
};
|