agentp 1.12.1 → 1.14.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/README.md +90 -10
- package/bin/agentp +372 -135
- package/bin/ocmux +334 -7
- package/docs/specification.md +26 -4
- package/lib/ocmux.js +30 -5
- package/lib/opencode.js +518 -17
- package/package.json +1 -1
package/bin/ocmux
CHANGED
|
@@ -24,7 +24,10 @@ const {
|
|
|
24
24
|
resurrectServer,
|
|
25
25
|
startServer,
|
|
26
26
|
getActiveServer,
|
|
27
|
+
activateServer,
|
|
28
|
+
tuiAttachCommand,
|
|
27
29
|
} = require('../lib/ocmux');
|
|
30
|
+
const { listSessions, listModels, switchModel, resolveModelRef } = require('../lib/opencode');
|
|
28
31
|
|
|
29
32
|
// ---------------------------------------------------------------------------
|
|
30
33
|
// Constants
|
|
@@ -182,6 +185,15 @@ function printHelp() {
|
|
|
182
185
|
console.error(' Works even if no tmux window exists (stale state file).');
|
|
183
186
|
console.error(' --print-logs Pass --print-logs to the new opencode serve.');
|
|
184
187
|
console.error(' list List all running servers with their directories and URLs.');
|
|
188
|
+
console.error(' switch Interactively select a server to switch to.');
|
|
189
|
+
console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
|
|
190
|
+
console.error(' q or Ctrl+C quits. Prints the URL of the last selected server on exit.');
|
|
191
|
+
console.error(' model Switch the model of the newest session on the current server.');
|
|
192
|
+
console.error(' With no argument: interactive model picker (requires a TTY).');
|
|
193
|
+
console.error(' With <ref>: switch directly and print the server URL on stdout,');
|
|
194
|
+
console.error(' so `agentp $(ocmux model <ref>)` switches before the next prompt.');
|
|
195
|
+
console.error(' Partial names work when unique (e.g. "ocmux model deepseek",');
|
|
196
|
+
console.error(' "opencode-go/deepseek", "...#high"). Multiple matches open the picker.');
|
|
185
197
|
console.error('');
|
|
186
198
|
console.error(' -- Treat the next argument as a directory even if it matches');
|
|
187
199
|
console.error(' a subcommand name (e.g. "ocmux -- kill").');
|
|
@@ -234,13 +246,13 @@ function cmdSwitch(statefile, longMode) {
|
|
|
234
246
|
const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
|
|
235
247
|
const panes = panesR.stdout.trim().split('\n').filter(Boolean);
|
|
236
248
|
const lastPane = panes[panes.length - 1];
|
|
237
|
-
const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane,
|
|
249
|
+
const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
|
|
238
250
|
if (respawnR.status !== 0) {
|
|
239
|
-
tmux(['send-keys', '-t', lastPane,
|
|
251
|
+
tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
|
|
240
252
|
}
|
|
241
253
|
} else {
|
|
242
254
|
tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
|
|
243
|
-
|
|
255
|
+
tuiAttachCommand(url)]);
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
258
|
|
|
@@ -253,6 +265,278 @@ function cmdSwitch(statefile, longMode) {
|
|
|
253
265
|
outputUrl(url, serverDir, longMode);
|
|
254
266
|
}
|
|
255
267
|
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Subcommand: switch — interactive session selection
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function cmdSwitchMenu(longMode) {
|
|
273
|
+
const rows = listServers();
|
|
274
|
+
if (rows.length === 0) {
|
|
275
|
+
console.error('No opencode servers running.');
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
if (!process.stdin.isTTY) {
|
|
279
|
+
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
280
|
+
console.error('Use "ocmux list" to list servers, or run "ocmux <dir>" to switch directly.');
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const cols = process.stderr.columns || 80;
|
|
285
|
+
const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
|
|
286
|
+
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
287
|
+
const urlW = Math.max(...rows.map(r => r.url.length), 3);
|
|
288
|
+
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
289
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
290
|
+
const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + urlW + 2 + dirW);
|
|
291
|
+
|
|
292
|
+
let cursor = 0;
|
|
293
|
+
let activeRow = 0;
|
|
294
|
+
let lastUrl = null;
|
|
295
|
+
let lastDir = null;
|
|
296
|
+
let exited = false;
|
|
297
|
+
|
|
298
|
+
// Query tmux for the currently active window and map it to a row. Called at
|
|
299
|
+
// init and on every draw, so the highlight always reflects tmux's real state.
|
|
300
|
+
const refreshActive = () => {
|
|
301
|
+
const idx = activeWindowIndex();
|
|
302
|
+
if (idx == null) return;
|
|
303
|
+
for (let i = 0; i < rows.length; i++) {
|
|
304
|
+
if (rows[i].index === idx) { activeRow = i; return; }
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const cleanup = () => {
|
|
309
|
+
process.stderr.write('\x1b[?25h');
|
|
310
|
+
process.stderr.write('\x1b[?1049l');
|
|
311
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const finish = () => {
|
|
315
|
+
if (exited) return;
|
|
316
|
+
exited = true;
|
|
317
|
+
cleanup();
|
|
318
|
+
process.stdin.pause();
|
|
319
|
+
if (lastUrl) outputUrl(lastUrl, lastDir, longMode);
|
|
320
|
+
process.exit(0);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const draw = () => {
|
|
324
|
+
refreshActive();
|
|
325
|
+
let out = '\x1b[2J\x1b[H';
|
|
326
|
+
out += 'ocmux switch — j/k or arrows: move, Enter/Space: select (keep switching), q/Ctrl+C: quit\n\n';
|
|
327
|
+
for (let i = 0; i < rows.length; i++) {
|
|
328
|
+
const row = rows[i];
|
|
329
|
+
const marker = i === cursor ? '▶ ' : ' ';
|
|
330
|
+
const line = `${marker}${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(row.url, urlW)} ${pad(row.dir, dirW)}`;
|
|
331
|
+
if (i === activeRow) {
|
|
332
|
+
out += '\x1b[7m' + pad(line, lineWidth) + '\x1b[0m\n';
|
|
333
|
+
} else {
|
|
334
|
+
out += line + '\n';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
process.stderr.write(out);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const activate = (i) => {
|
|
341
|
+
const row = rows[i];
|
|
342
|
+
try {
|
|
343
|
+
activateServer(row.dir, row.index, row.url);
|
|
344
|
+
lastUrl = row.url;
|
|
345
|
+
lastDir = row.dir;
|
|
346
|
+
} catch (err) {
|
|
347
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
348
|
+
}
|
|
349
|
+
draw();
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
process.stdin.on('keypress', (str, key) => {
|
|
353
|
+
if (key.ctrl && key.name === 'c') return finish();
|
|
354
|
+
if (key.name === 'q') return finish();
|
|
355
|
+
if (key.name === 'up' || str === 'k') {
|
|
356
|
+
cursor = (cursor + rows.length - 1) % rows.length;
|
|
357
|
+
draw();
|
|
358
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
359
|
+
cursor = (cursor + 1) % rows.length;
|
|
360
|
+
draw();
|
|
361
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
362
|
+
activate(cursor);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
process.on('SIGINT', finish);
|
|
367
|
+
process.on('SIGTERM', finish);
|
|
368
|
+
|
|
369
|
+
readline.emitKeypressEvents(process.stdin);
|
|
370
|
+
process.stdin.setRawMode(true);
|
|
371
|
+
process.stdin.resume();
|
|
372
|
+
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
373
|
+
refreshActive();
|
|
374
|
+
cursor = activeRow;
|
|
375
|
+
draw();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Subcommand: model — switch the model of the server's most recent session
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
// Newest session id for a server (same heuristic as agentp's default).
|
|
383
|
+
function newestSessionId(sessions) {
|
|
384
|
+
if (!sessions || sessions.length === 0) return null;
|
|
385
|
+
const sorted = sessions
|
|
386
|
+
.filter(s => s && s.id && s.time && (s.time.updated || s.time.created))
|
|
387
|
+
.sort((a, b) => {
|
|
388
|
+
const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
|
|
389
|
+
const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
|
|
390
|
+
return bTime - aTime;
|
|
391
|
+
});
|
|
392
|
+
return sorted.length > 0 ? sorted[0].id : (sessions[0] && sessions[0].id) || null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Interactive model picker (mirrors the ocmux switch menu; single selection).
|
|
396
|
+
// Resolves with the chosen model or null on quit.
|
|
397
|
+
function modelMenu(rows) {
|
|
398
|
+
return new Promise((resolve) => {
|
|
399
|
+
if (!process.stdin.isTTY || rows.length === 0) return resolve(null);
|
|
400
|
+
|
|
401
|
+
// Display name when it differs from the canonical label (e.g. "Kimi K2 (opencode/kimi-k2)").
|
|
402
|
+
const display = (r) => (r.name && r.name !== r.label ? `${r.name} (${r.label})` : r.label);
|
|
403
|
+
const maxLen = Math.max(
|
|
404
|
+
...rows.map(r => display(r).length),
|
|
405
|
+
...rows.map(r => (r.variants || []).join(',').length),
|
|
406
|
+
8,
|
|
407
|
+
);
|
|
408
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
409
|
+
let cursor = 0;
|
|
410
|
+
let done = false;
|
|
411
|
+
|
|
412
|
+
const cleanup = () => {
|
|
413
|
+
process.stderr.write('\x1b[?25h');
|
|
414
|
+
process.stderr.write('\x1b[?1049l');
|
|
415
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
416
|
+
process.stdin.pause();
|
|
417
|
+
};
|
|
418
|
+
const finish = (value) => {
|
|
419
|
+
if (done) return;
|
|
420
|
+
done = true;
|
|
421
|
+
cleanup();
|
|
422
|
+
process.stdin.removeAllListeners('keypress');
|
|
423
|
+
process.removeListener('SIGINT', onSig);
|
|
424
|
+
resolve(value);
|
|
425
|
+
};
|
|
426
|
+
const onSig = () => finish(null);
|
|
427
|
+
process.on('SIGINT', onSig);
|
|
428
|
+
|
|
429
|
+
const draw = () => {
|
|
430
|
+
let out = '\x1b[2J\x1b[H';
|
|
431
|
+
out += 'ocmux model — j/k or arrows: move, Enter: switch, q/Ctrl+C: quit\n\n';
|
|
432
|
+
for (let i = 0; i < rows.length; i++) {
|
|
433
|
+
const row = rows[i];
|
|
434
|
+
const marker = i === cursor ? '▶ ' : ' ';
|
|
435
|
+
const variants = (row.variants || []).join(',') || '—';
|
|
436
|
+
out += `${marker}${pad(display(row), maxLen)} ${variants}\n`;
|
|
437
|
+
}
|
|
438
|
+
process.stderr.write(out);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
process.stdin.on('keypress', (str, key) => {
|
|
442
|
+
if (key.ctrl && key.name === 'c') return finish(null);
|
|
443
|
+
if (key.name === 'q') return finish(null);
|
|
444
|
+
if (key.name === 'up' || str === 'k') {
|
|
445
|
+
cursor = (cursor + rows.length - 1) % rows.length;
|
|
446
|
+
draw();
|
|
447
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
448
|
+
cursor = (cursor + 1) % rows.length;
|
|
449
|
+
draw();
|
|
450
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
451
|
+
finish(rows[cursor]);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
readline.emitKeypressEvents(process.stdin);
|
|
456
|
+
process.stdin.setRawMode(true);
|
|
457
|
+
process.stdin.resume();
|
|
458
|
+
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
459
|
+
draw();
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function cmdModel(modelRef, longMode) {
|
|
464
|
+
const sf = findParentStatefile(process.cwd());
|
|
465
|
+
if (!sf) {
|
|
466
|
+
console.error(`Error: no opencode server found in ${process.cwd()} or any of its parents.`);
|
|
467
|
+
console.error("Run 'ocmux serve' to create a new server.");
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
const state = readState(sf);
|
|
471
|
+
if (!state || !state.url) {
|
|
472
|
+
console.error(`Error: invalid state file ${sf}`);
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Fail fast before any HTTP: interactive picker requires a TTY.
|
|
477
|
+
if (!modelRef && !process.stdin.isTTY) {
|
|
478
|
+
console.error('Error: "model" with no argument is interactive and requires a TTY.');
|
|
479
|
+
console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const sessions = await listSessions(state.url).catch((err) => {
|
|
484
|
+
console.error(`Error connecting to ${state.url}: ${err.message}`);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
});
|
|
487
|
+
const sessionId = newestSessionId(sessions);
|
|
488
|
+
if (!sessionId) {
|
|
489
|
+
console.error(`Error: no sessions found on ${state.url} (${path.dirname(sf)}).`);
|
|
490
|
+
console.error('Open a session in the TUI first, or send a prompt with agentp.');
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const models = await listModels(state.url).catch((err) => {
|
|
495
|
+
console.error(`Error listing models: ${err.message}`);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
});
|
|
498
|
+
if (models.length === 0) {
|
|
499
|
+
console.error('Error: no models available from this server.');
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let pick = null;
|
|
504
|
+
if (modelRef) {
|
|
505
|
+
const { model, matches, error } = resolveModelRef(models, modelRef);
|
|
506
|
+
if (model) {
|
|
507
|
+
pick = model;
|
|
508
|
+
} else if (matches.length > 1 && process.stdin.isTTY) {
|
|
509
|
+
console.error(`Multiple models match "${modelRef}", select one:`);
|
|
510
|
+
pick = await modelMenu(matches);
|
|
511
|
+
} else {
|
|
512
|
+
console.error(`Error: ${error}`);
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
} else if (process.stdin.isTTY) {
|
|
516
|
+
pick = await modelMenu(models);
|
|
517
|
+
} else {
|
|
518
|
+
// Unreachable (guarded above) — kept for safety.
|
|
519
|
+
console.error('Error: "model" with no argument is interactive and requires a TTY.');
|
|
520
|
+
console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!pick) {
|
|
525
|
+
console.error('Cancelled.');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await switchModel(state.url, sessionId, pick).catch((err) => {
|
|
530
|
+
console.error(`Error switching model: ${err.message}`);
|
|
531
|
+
process.exit(1);
|
|
532
|
+
});
|
|
533
|
+
const label = pick.label + (pick.variant ? `#${pick.variant}` : '');
|
|
534
|
+
console.error(`Switched session ${sessionId} to ${label}.`);
|
|
535
|
+
// Print the URL on stdout so `agentp $(ocmux model <ref>)` composes: the
|
|
536
|
+
// switch (side effect) takes effect before agentp proceeds with the prompt.
|
|
537
|
+
outputUrl(state.url, path.dirname(sf), longMode);
|
|
538
|
+
}
|
|
539
|
+
|
|
256
540
|
// ---------------------------------------------------------------------------
|
|
257
541
|
// Subcommand: list
|
|
258
542
|
// ---------------------------------------------------------------------------
|
|
@@ -364,7 +648,7 @@ function doNew(targetDir, longMode, printLogs) {
|
|
|
364
648
|
const tuiPane = splitResult.stdout.trim();
|
|
365
649
|
sleep(0.2);
|
|
366
650
|
tmux(['send-keys', '-t', tuiPane,
|
|
367
|
-
|
|
651
|
+
tuiAttachCommand(url), 'Enter']);
|
|
368
652
|
sleep(0.5);
|
|
369
653
|
if (tuiPane) tmux(['resize-pane', '-Z', '-t', tuiPane]);
|
|
370
654
|
|
|
@@ -497,6 +781,7 @@ function main() {
|
|
|
497
781
|
|
|
498
782
|
let subcommand = null;
|
|
499
783
|
let targetDir = null;
|
|
784
|
+
let modelRef = null;
|
|
500
785
|
|
|
501
786
|
for (const arg of positional) {
|
|
502
787
|
if (dirIsForced) {
|
|
@@ -506,12 +791,15 @@ function main() {
|
|
|
506
791
|
process.exit(1);
|
|
507
792
|
}
|
|
508
793
|
targetDir = resolveDir(arg);
|
|
509
|
-
} else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect') {
|
|
794
|
+
} else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch' || arg === 'model') {
|
|
510
795
|
if (subcommand) {
|
|
511
796
|
console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
|
|
512
797
|
process.exit(1);
|
|
513
798
|
}
|
|
514
799
|
subcommand = arg;
|
|
800
|
+
} else if (subcommand === 'model' && modelRef === null) {
|
|
801
|
+
// model reference (supports partial matches) — not a directory
|
|
802
|
+
modelRef = arg;
|
|
515
803
|
} else {
|
|
516
804
|
if (targetDir) {
|
|
517
805
|
console.error(`Error: unexpected argument '${arg}'`);
|
|
@@ -566,6 +854,41 @@ function main() {
|
|
|
566
854
|
process.exit(1);
|
|
567
855
|
}
|
|
568
856
|
cmdList();
|
|
857
|
+
} else if (subcommand === 'switch') {
|
|
858
|
+
if (gitMode) {
|
|
859
|
+
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
860
|
+
process.exit(1);
|
|
861
|
+
}
|
|
862
|
+
if (printLogs) {
|
|
863
|
+
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
if (targetDir !== process.cwd()) {
|
|
867
|
+
console.error("Error: 'switch' does not accept a directory path.");
|
|
868
|
+
process.exit(1);
|
|
869
|
+
}
|
|
870
|
+
ensureSession();
|
|
871
|
+
cmdSwitchMenu(longMode);
|
|
872
|
+
} else if (subcommand === 'model') {
|
|
873
|
+
if (gitMode) {
|
|
874
|
+
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
if (printLogs) {
|
|
878
|
+
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
879
|
+
process.exit(1);
|
|
880
|
+
}
|
|
881
|
+
if (targetDir !== process.cwd()) {
|
|
882
|
+
console.error("Error: 'model' does not accept a directory path.");
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
885
|
+
// cmdModel is async; swallow the (test-mocked) process.exit throw.
|
|
886
|
+
cmdModel(modelRef, longMode).catch((err) => {
|
|
887
|
+
if (!err || !err.message || !err.message.startsWith('EXIT:')) {
|
|
888
|
+
console.error(err ? err.message : String(err));
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
569
892
|
} else if (subcommand === 'kill') {
|
|
570
893
|
if (gitMode) {
|
|
571
894
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
@@ -606,7 +929,7 @@ function main() {
|
|
|
606
929
|
// Default mode — find an existing server and switch to it
|
|
607
930
|
let sf = findParentStatefile(targetDir);
|
|
608
931
|
if (!sf) {
|
|
609
|
-
console.
|
|
932
|
+
console.log(`Error: no opencode server found in ${targetDir} or any of its parents.`);
|
|
610
933
|
console.error("Run 'ocmux serve' to create a new server.");
|
|
611
934
|
process.exit(1);
|
|
612
935
|
}
|
|
@@ -628,4 +951,8 @@ function main() {
|
|
|
628
951
|
}
|
|
629
952
|
}
|
|
630
953
|
|
|
631
|
-
main
|
|
954
|
+
if (require.main === module) {
|
|
955
|
+
main();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
module.exports = { main };
|
package/docs/specification.md
CHANGED
|
@@ -31,13 +31,25 @@ Reads stdin, sends to the most recent or named OpenCode session, streams answer
|
|
|
31
31
|
**Options:**
|
|
32
32
|
|
|
33
33
|
| Flag | Effect |
|
|
34
|
-
|
|
34
|
+
|---|---|---|
|
|
35
35
|
| `--qa` | Print prompt/answer with rulers; auto-detect tgagentp |
|
|
36
|
+
| `--defer [N]` | Deferred execution — submit a prompt and get a ticket immediately, or wait up to N seconds (default `0`) for the answer; piping the ticket back retrieves the result later |
|
|
37
|
+
| `--onlineTicket` | Print deferred tickets on a single line (default: pretty-printed JSON) |
|
|
36
38
|
| `--tg` | Forward answer via tgagentp gateway (error if unavailable) |
|
|
37
39
|
| `--no-tg` | Explicitly disable Telegram forwarding |
|
|
38
40
|
| `--flush` | Flush recorded buffer without prepending |
|
|
39
41
|
| `--getLast N` | Retrieve last N assistant answers from session history |
|
|
40
42
|
|
|
43
|
+
**Deferred tickets (`--defer`):**
|
|
44
|
+
|
|
45
|
+
Ticket format on stdout: `agentp_ticket` followed by a JSON object `{ ctime, path, server, sessionId, elapsed, defer }`, printed as pretty-printed (multi-line) JSON by default or on a single line with `--onlineTicket`. Both formats are accepted when piping a ticket back.
|
|
46
|
+
|
|
47
|
+
- First print omits `elapsed`; `defer` is included only when it was `> 0`.
|
|
48
|
+
- Re-prints (answer not ready yet) include `elapsed` computed from `ctime` (0 when `ctime` is missing/unparseable).
|
|
49
|
+
- A ticket piped back to `agentp --defer` ignores the CLI `--defer` value and uses the ticket's own `defer` (default 0): ready → print answer + delete temp file; not ready → re-print the ticket with updated `elapsed`; file missing → error, exit 1.
|
|
50
|
+
- If a ticket is followed by additional text and the answer is not ready, `agentp --defer` sends that text to the ticket's `server`/`sessionId` with `sendToSessionAsync()` (`POST /session/:id/prompt_async`), stores it in a ticket sidecar file, and re-prints the ticket without echoing the extra text. When the final answer is retrieved with `--qa`, stored follow-ups are injected into the prompt block under `📝` separators; without `--qa`, they are omitted from output. If the answer is ready, appended text is not sent and is printed after the returned answer. Older tickets without `server`/`sessionId` can retrieve results but cannot queue follow-up text.
|
|
51
|
+
- New run with `--defer N` (N > 0): wait up to N seconds polling the temp file; answer arrives in time → print + delete; else print the ticket and keep the background child alive.
|
|
52
|
+
|
|
41
53
|
**Protocol:**
|
|
42
54
|
|
|
43
55
|
1. Reads all stdin → string
|
|
@@ -63,15 +75,23 @@ Manages per-project OpenCode servers in a persistent `Opencode` tmux session.
|
|
|
63
75
|
| `new [dir]` | Alias for `serve` (deprecated) |
|
|
64
76
|
| `kill [dir]` | Kill server, remove tmux window + `.ocmux.json` |
|
|
65
77
|
| `resurrect [dir]` | Recover dead server: kill old window, remove state file, create fresh server + TUI |
|
|
78
|
+
| `model [ref]` | Switch the model of the newest session (interactive picker without `ref`; prints the server URL on success so `agentp $(ocmux model <ref>)` composes) |
|
|
79
|
+
| `switch` | Interactive session picker (TTY required) |
|
|
66
80
|
| `list [-l]` | List all running servers |
|
|
67
81
|
| _(no arg)_ | Switch to existing server (searches upward for `.ocmux.json`) |
|
|
68
82
|
|
|
83
|
+
**`model` behavior:** Targets the newest session on the server found upward from `$PWD`. With no argument it opens an interactive model picker (all models, arrow/`j`/`k`, Enter to switch, `q`/`Ctrl+C` to quit; requires a TTY). With a reference it switches directly; references may be full (`providerID/modelID`), partial (matched uniquely against label or bare id), and carry a variant suffix (`#variant`). On success it writes the server URL to stdout (plus a confirmation to stderr), so `agentp $(ocmux model <ref>)` switches the model before the next prompt is sent. Multiple matches open the picker on a TTY, otherwise error listing the candidates. Rejects `--git`, `--GIT`, `--print-logs`, and directory arguments.
|
|
84
|
+
|
|
85
|
+
**`switch` behavior:** Renders an interactive menu of all running servers on an alt screen with columns `dirname | status | url | full path`. `j`/`k` or arrow keys move the cursor; `Enter`/`Space` activates the selected server (switches its tmux window) and keeps the menu open; `q`/`Ctrl+C` exits and prints the URL of the last selected server (if any). The row matching the tmux-active window (queried live from tmux via `activeWindowIndex()` on every redraw) is highlighted across the full line width, so the highlight tracks both local activations and external tmux window switches. Errors (exit 1) when no TTY or no servers. Rejects `--git`, `--GIT`, `--print-logs`, and directory arguments.
|
|
86
|
+
|
|
87
|
+
**Default no-server behavior:** When no `.ocmux.json` is found for the current/target directory or any parent, default mode prints the primary `Error: no opencode server found...` line to stdout and the `Run 'ocmux serve'...` hint to stderr. This makes command substitution (`agentp $(ocmux)`) fail safely instead of passing no argument and falling back to `agentp`'s default server.
|
|
88
|
+
|
|
69
89
|
**Flags:** `--git`, `--GIT`, `--print-logs`, `-l`, `--version`
|
|
70
90
|
|
|
71
91
|
**Window layout:**
|
|
72
92
|
|
|
73
93
|
- Pane 0: server (`opencode serve --port 0 2>&1 | tee <logfile>`)
|
|
74
|
-
- Pane 1+: TUI (`opencode attach --continue '<url>'`)
|
|
94
|
+
- Pane 1+: TUI (`opencode --server '<url>' --continue` on OpenCode v2; `opencode attach --continue '<url>'` on older versions — chosen automatically via `opencode --version`)
|
|
75
95
|
- Log: `/tmp/opencode-serve-<hashDir(dir)>.log`
|
|
76
96
|
- State: `<dir>/.ocmux.json` (contains `url`, `logfile`, `window_index`)
|
|
77
97
|
|
|
@@ -218,6 +238,8 @@ Server log file. Continuously written by `tee` in the server pane. Polled by `do
|
|
|
218
238
|
opencode server listening on http://localhost:40999
|
|
219
239
|
```
|
|
220
240
|
|
|
241
|
+
OpenCode v2 prints `server listening on http://...` (without the `opencode ` prefix); both formats are matched for URL extraction.
|
|
242
|
+
|
|
221
243
|
---
|
|
222
244
|
|
|
223
245
|
## Inter-Process Communication
|
|
@@ -264,10 +286,10 @@ All servers live in a single tmux session named `Opencode`. Each project gets on
|
|
|
264
286
|
Session: Opencode
|
|
265
287
|
├── Window 3: /home/user/project-a
|
|
266
288
|
│ ├── Pane 0: opencode serve --port 0 ... (server)
|
|
267
|
-
│ └── Pane 1: opencode
|
|
289
|
+
│ └── Pane 1: TUI (opencode --server <url> --continue / attach, zoomed)
|
|
268
290
|
├── Window 4: /home/user/project-b
|
|
269
291
|
│ ├── Pane 0: opencode serve --port 0 ...
|
|
270
|
-
│ └── Pane 1: opencode
|
|
292
|
+
│ └── Pane 1: TUI (opencode --server <url> --continue / attach)
|
|
271
293
|
```
|
|
272
294
|
|
|
273
295
|
Window names are the full project directory path. Pane 0 is always the server; pane 1+ is the TUI. The TUI pane is zoomed on switch/create. Dead TUI panes are auto-restarted on switch.
|
package/lib/ocmux.js
CHANGED
|
@@ -6,7 +6,30 @@ const crypto = require('crypto');
|
|
|
6
6
|
const child_process = require('child_process');
|
|
7
7
|
|
|
8
8
|
const SESSION = 'Opencode';
|
|
9
|
-
|
|
9
|
+
// OpenCode v2 prints `server listening on http://...`; older versions printed
|
|
10
|
+
// `opencode server listening on http://...`. Accept both.
|
|
11
|
+
const URL_RE = /(?:opencode )?server listening on (http:\/\/[^\s]*)/;
|
|
12
|
+
|
|
13
|
+
// Detect the OpenCode major version: v2 runs the TUI with `opencode --server`,
|
|
14
|
+
// older versions used the `attach` subcommand. Returns null when undetectable.
|
|
15
|
+
function detectOcMajor() {
|
|
16
|
+
const r = child_process.spawnSync('opencode', ['--version'], { encoding: 'utf8' });
|
|
17
|
+
const out = (r.stdout || '') + (r.stderr || '');
|
|
18
|
+
const m = out.match(/v?(\d+)\.(\d+)\./);
|
|
19
|
+
return m ? parseInt(m[1], 10) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Command to attach the TUI to a running server. OpenCode v2 dropped the
|
|
23
|
+
// `attach` subcommand in favor of `opencode --server <url>`; older versions
|
|
24
|
+
// still use `opencode attach --continue <url>`. Falls back to the legacy form
|
|
25
|
+
// when the version cannot be detected (keeps old installations working).
|
|
26
|
+
function tuiAttachCommand(url) {
|
|
27
|
+
const major = detectOcMajor();
|
|
28
|
+
if (major !== null && major >= 2) {
|
|
29
|
+
return `opencode --server '${url}' --continue`;
|
|
30
|
+
}
|
|
31
|
+
return `opencode attach --continue '${url}'`;
|
|
32
|
+
}
|
|
10
33
|
|
|
11
34
|
function _tmux(args) {
|
|
12
35
|
const result = child_process.spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
@@ -182,14 +205,14 @@ function activateServer(dir, index, url) {
|
|
|
182
205
|
if (panesR.status === 0) {
|
|
183
206
|
const panes = panesR.stdout.trim().split('\n').filter(Boolean);
|
|
184
207
|
const lastPane = panes[panes.length - 1];
|
|
185
|
-
const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane,
|
|
208
|
+
const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
|
|
186
209
|
if (respawnR.status !== 0) {
|
|
187
|
-
_tmux(['send-keys', '-t', lastPane,
|
|
210
|
+
_tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
|
|
188
211
|
}
|
|
189
212
|
}
|
|
190
213
|
} else {
|
|
191
214
|
_tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', dir,
|
|
192
|
-
|
|
215
|
+
tuiAttachCommand(url)]);
|
|
193
216
|
}
|
|
194
217
|
}
|
|
195
218
|
|
|
@@ -312,7 +335,7 @@ function startServer(dir, printLogs) {
|
|
|
312
335
|
const splitResult = _tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', dir]);
|
|
313
336
|
const tuiPane = splitResult.stdout.trim();
|
|
314
337
|
sleep(0.2);
|
|
315
|
-
_tmux(['send-keys', '-t', tuiPane,
|
|
338
|
+
_tmux(['send-keys', '-t', tuiPane, tuiAttachCommand(url), 'Enter']);
|
|
316
339
|
sleep(0.5);
|
|
317
340
|
if (tuiPane) _tmux(['resize-pane', '-Z', '-t', tuiPane]);
|
|
318
341
|
|
|
@@ -346,4 +369,6 @@ module.exports = {
|
|
|
346
369
|
startServer,
|
|
347
370
|
serverStatus,
|
|
348
371
|
listDeadServers,
|
|
372
|
+
detectOcMajor,
|
|
373
|
+
tuiAttachCommand,
|
|
349
374
|
};
|