agentp 2.0.0 → 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/bin/agentp CHANGED
@@ -18,9 +18,9 @@ const {
18
18
  selectSession,
19
19
  sortSessionsByRecency,
20
20
  interruptSession,
21
+ getActiveSessions,
21
22
  } = require('../lib/opencode');
22
23
  const projectState = require('../lib/project-state');
23
- const ocmuxLib = require('../lib/ocmux');
24
24
 
25
25
  const TGAGENTP_PORT_FILE = '/tmp/tgagentp-port';
26
26
 
@@ -168,7 +168,7 @@ function parseDeferredTicketInput(text) {
168
168
  if (!data || typeof data.path !== 'string' || !data.path) return null;
169
169
  const rest = afterPrefix.slice(jsonEnd);
170
170
  const followupText = rest.trim() ? stripFollowupRow(rest.trimStart()) : '';
171
- return {
171
+ const ticket = {
172
172
  ctime: typeof data.ctime === 'string' ? data.ctime : null,
173
173
  path: data.path,
174
174
  defer: typeof data.defer === 'number' && data.defer > 0 ? data.defer : 0,
@@ -177,6 +177,10 @@ function parseDeferredTicketInput(text) {
177
177
  cancelled: data.cancelled === true,
178
178
  followupText,
179
179
  };
180
+ if (Array.isArray(data.sessionIds)) {
181
+ ticket.sessionIds = data.sessionIds.filter(x => typeof x === 'string' && x);
182
+ }
183
+ return ticket;
180
184
  }
181
185
 
182
186
  function parseDeferredTicket(text) {
@@ -193,6 +197,7 @@ function formatTicket(ticket, elapsed, compact) {
193
197
  const fields = { ctime: ticket.ctime, path: ticket.path };
194
198
  if (ticket.server) fields.server = ticket.server;
195
199
  if (ticket.sessionId) fields.sessionId = ticket.sessionId;
200
+ if (Array.isArray(ticket.sessionIds) && ticket.sessionIds.length > 0) fields.sessionIds = ticket.sessionIds;
196
201
  if (elapsed !== undefined) fields.elapsed = elapsed;
197
202
  if (ticket.defer > 0) fields.defer = ticket.defer;
198
203
  // Always present so it can be flipped to true and piped back to cancel.
@@ -204,8 +209,12 @@ function formatTicket(ticket, elapsed, compact) {
204
209
  // the ticket. Prints a message and exits 0.
205
210
  async function cancelTicket(ticket) {
206
211
  let interrupted = false;
207
- if (ticket.server && ticket.sessionId) {
208
- interrupted = await interruptSession(ticket.server, ticket.sessionId);
212
+ const ids = Array.isArray(ticket.sessionIds) && ticket.sessionIds.length > 0
213
+ ? ticket.sessionIds
214
+ : (ticket.sessionId ? [ticket.sessionId] : []);
215
+ if (ticket.server && ids.length > 0) {
216
+ const interruptedAll = await Promise.all(ids.map(id => interruptSession(ticket.server, id)));
217
+ interrupted = interruptedAll.some(Boolean);
209
218
  }
210
219
  try { fs.unlinkSync(ticket.path); } catch {}
211
220
  try { fs.unlinkSync(ticket.path + '.lock'); } catch {}
@@ -269,7 +278,9 @@ async function resolveTargetSession(serverBase, sessionName, newSession, opts =
269
278
  }
270
279
 
271
280
  if (sessions && sessions.length > 0) {
272
- const sorted = sortSessionsByRecency(sessions).filter(s => s.id);
281
+ // Only "main" sessions (no parentID) are considered as implicit targets —
282
+ // child/subagent sessions are work products, not conversation targets.
283
+ const sorted = sortSessionsByRecency(sessions).filter(s => s.id && !s.parentID);
273
284
  if (sorted.length > 0) return sorted[0].id;
274
285
  }
275
286
 
@@ -414,6 +425,114 @@ function outputReadyResult(filePath, result, qaMode, unusedFollowupText, header)
414
425
  process.exit(0);
415
426
  }
416
427
 
428
+ // ── Broadcast (ocmux "Space" multi-session send) ───────────────────
429
+
430
+ // One answer section: a heading with the session name and a separator below it.
431
+ function buildBroadcastSection(title, id, answer) {
432
+ let s = `\n💬 ${title} [${id}]\n${'━'.repeat(40)}\n`;
433
+ s += answer;
434
+ if (!answer.endsWith('\n')) s += '\n';
435
+ return s;
436
+ }
437
+
438
+ // Send a prompt to every session in `ids` and collect the answers.
439
+ // deps.getActiveSessions(server) -> Set of busy session ids
440
+ // deps.getSession(server, id) -> session object (title)
441
+ // deps.sendToSession(server, id, text) -> answer text
442
+ // deps.readBroadcast() -> current .ocmux.json broadcast list (null/[] = cancelled)
443
+ // Waits for each session to become idle before sending; stops as soon as the
444
+ // broadcast list disappears (ocmux exited broadcast mode early).
445
+ async function runBroadcastSend(server, ids, prompt, deps) {
446
+ const { getActiveSessions, getSession, sendToSession, readBroadcast, getPrompt, pollMs = 1000, busyTimeoutMs = 15 * 60 * 1000 } = deps;
447
+ const initial = new Set(ids.map(String));
448
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
449
+ const cancelled = () => {
450
+ try { return !isBroadcastActive(readBroadcast(), initial); } catch { return false; }
451
+ };
452
+
453
+ // Race a sent session's answer against ocmux leaving broadcast mode. We do
454
+ // not interrupt the model: we merely stop waiting/discard its later answer,
455
+ // exactly as the UI contract promises.
456
+ const awaitAnswerOrCancel = (promise) => new Promise((resolve) => {
457
+ let done = false;
458
+ const finish = (value) => {
459
+ if (done) return;
460
+ done = true;
461
+ clearInterval(timer);
462
+ resolve(value);
463
+ };
464
+ const timer = setInterval(() => {
465
+ if (cancelled()) finish({ cancelled: true });
466
+ }, pollMs);
467
+ promise.then(answer => finish({ answer }), err => finish({ error: err }));
468
+ });
469
+
470
+ const runOne = async (id) => {
471
+ let title = id;
472
+ try {
473
+ const s = await getSession(server, id);
474
+ title = (s && (s.title || s.id)) || id;
475
+ } catch {
476
+ return { missing: { id, title, error: 'Unable to read session', timestamp: new Date().toISOString() } };
477
+ }
478
+ // Wait for the session to become idle (skip prompts into a busy session).
479
+ const deadline = Date.now() + busyTimeoutMs;
480
+ let busy = true;
481
+ while (busy && Date.now() < deadline) {
482
+ if (cancelled()) {
483
+ return { missing: { id, title, error: 'Broadcast cancelled while waiting for idle', timestamp: new Date().toISOString() } };
484
+ }
485
+ const active = await getActiveSessions(server);
486
+ if (!active.has(String(id))) busy = false;
487
+ else await sleep(pollMs);
488
+ }
489
+ if (busy) {
490
+ return { missing: { id, title, error: 'Session stayed busy until timeout', timestamp: new Date().toISOString() } };
491
+ }
492
+ try {
493
+ const result = await awaitAnswerOrCancel(sendToSession(server, id, getPrompt ? getPrompt(id, prompt) : prompt));
494
+ if (result.cancelled) {
495
+ return { missing: { id, title, error: 'Broadcast cancelled before this session replied', timestamp: new Date().toISOString() } };
496
+ }
497
+ if (result.error) {
498
+ return { missing: { id, title, error: result.error.message || String(result.error), timestamp: new Date().toISOString() } };
499
+ }
500
+ return { result: { id, title, answer: result.answer } };
501
+ } catch (err) {
502
+ return { missing: { id, title, error: err.message || String(err), timestamp: new Date().toISOString() } };
503
+ }
504
+ };
505
+
506
+ // Run independently so one busy/model-limited session never prevents all
507
+ // other selected sessions from receiving the prompt.
508
+ const settled = await Promise.all(ids.map(runOne));
509
+ const results = settled.filter(x => x.result).map(x => x.result);
510
+ const missing = settled.filter(x => x.missing).map(x => x.missing);
511
+ return { results, missing, requested: ids.length };
512
+ }
513
+
514
+ // A broadcast list is still active while it is an array that contains every
515
+ // session we were asked to reach.
516
+ function isBroadcastActive(broadcast, initial) {
517
+ return Array.isArray(broadcast)
518
+ && broadcast.length >= 2
519
+ && [...initial].every((id) => broadcast.includes(id));
520
+ }
521
+
522
+ // Assemble the final broadcast output: heading per session + missing note.
523
+ function formatBroadcastResults({ results, missing, requested }) {
524
+ let out = `🌐 Broadcast to ${requested} sessions\n`;
525
+ for (const r of results) out += buildBroadcastSection(r.title, r.id, r.answer);
526
+ if (missing.length > 0) {
527
+ out += '\n⚠️ Prompt not completed in the following sessions:\n';
528
+ for (const m of missing) {
529
+ out += `- 💬 ${m.title} [${m.id}]\n - Error: ${m.error || 'Unknown'}\n`;
530
+ if (m.timestamp) out += ` - At: ${m.timestamp}\n`;
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+
417
536
  // Structured header (project path + session id/title) prepended to --qa output,
418
537
  // so a piped answer is self-describing about where it came from.
419
538
  async function buildSessionHeader(server, sessionId) {
@@ -434,7 +553,7 @@ async function buildSessionHeader(server, sessionId) {
434
553
 
435
554
  // Header for deferred-ticket retrieval (ticket carries server + sessionId).
436
555
  async function ticketHeader(ticket, qaMode) {
437
- if (qaMode && ticket && ticket.server && ticket.sessionId) {
556
+ if (qaMode && ticket && ticket.server && ticket.sessionId && !(Array.isArray(ticket.sessionIds) && ticket.sessionIds.length >= 2)) {
438
557
  try { return await buildSessionHeader(ticket.server, ticket.sessionId); } catch {}
439
558
  }
440
559
  return '';
@@ -457,7 +576,7 @@ const FINAL_ROW = ' —————————————————\n';
457
576
  // Write the prompt to a temp file, lock the output file, and spawn a detached
458
577
  // `--defer-child` process that runs the prompt and writes the answer. Returns
459
578
  // the ticket describing this deferred run.
460
- function spawnDeferredChild({ promptText, serverBase, sessionName, newSession, qaMode, tgMode, flushMode, targetSessionId, deferSeconds }) {
579
+ function spawnDeferredChild({ promptText, serverBase, sessionName, newSession, qaMode, tgMode, flushMode, targetSessionId, targetSessionIds, deferSeconds }) {
461
580
  const tempPath = generateTempFilename();
462
581
  const promptTempPath = tempPath + '.prompt';
463
582
 
@@ -471,6 +590,7 @@ function spawnDeferredChild({ promptText, serverBase, sessionName, newSession, q
471
590
  defer: deferSeconds || 0,
472
591
  server: serverBase,
473
592
  sessionId: targetSessionId,
593
+ sessionIds: targetSessionIds,
474
594
  cancelled: false,
475
595
  };
476
596
 
@@ -488,7 +608,10 @@ function spawnDeferredChild({ promptText, serverBase, sessionName, newSession, q
488
608
  childArgs.push('--session', sessionName);
489
609
  if (newSession) childArgs.push('--new');
490
610
  }
491
- childArgs.push('--resolved-session-id', targetSessionId);
611
+ if (targetSessionId) childArgs.push('--resolved-session-id', targetSessionId);
612
+ if (Array.isArray(targetSessionIds) && targetSessionIds.length > 0) {
613
+ childArgs.push('--resolved-session-ids', JSON.stringify(targetSessionIds));
614
+ }
492
615
  childArgs.push(serverBase);
493
616
 
494
617
  const child = child_process.spawn(process.execPath, childArgs, {
@@ -522,6 +645,7 @@ async function main() {
522
645
  let outputFile = null;
523
646
  let sessionName = null;
524
647
  let resolvedSessionId = null;
648
+ let resolvedSessionIds = null;
525
649
  let newSession = false;
526
650
  // tgMode: false = disabled, 'auto' = auto-detect, true = explicit --tg
527
651
  let tgMode = null;
@@ -624,6 +748,16 @@ async function main() {
624
748
  console.error('Error: --resolved-session-id requires a session id');
625
749
  process.exit(1);
626
750
  }
751
+ } else if (args[i] === '--resolved-session-ids') {
752
+ i++;
753
+ try {
754
+ const ids = JSON.parse(args[i]);
755
+ if (!Array.isArray(ids) || ids.length < 2 || ids.some(id => typeof id !== 'string' || !id)) throw new Error('bad ids');
756
+ resolvedSessionIds = ids;
757
+ } catch {
758
+ console.error('Error: --resolved-session-ids requires a JSON array of at least two session ids');
759
+ process.exit(1);
760
+ }
627
761
  } else if (args[i] === '--new') {
628
762
  newSession = true;
629
763
  } else if (args[i] === '--getLast') {
@@ -844,15 +978,22 @@ async function main() {
844
978
  }
845
979
 
846
980
  // This is a regular prompt — create deferred execution
847
- let targetSessionId;
848
- try {
849
- targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
850
- directory: projectDir,
851
- preferredSession,
852
- });
853
- } catch (err) {
854
- console.error(err.message);
855
- process.exit(1);
981
+ const broadcastIds = (resolvedSessionIds && resolvedSessionIds.length >= 2)
982
+ ? resolvedSessionIds
983
+ : ((ctx && ctx.state && Array.isArray(ctx.state.broadcast) && ctx.state.broadcast.length >= 2)
984
+ ? ctx.state.broadcast
985
+ : null);
986
+ let targetSessionId = null;
987
+ if (!broadcastIds) {
988
+ try {
989
+ targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
990
+ directory: projectDir,
991
+ preferredSession,
992
+ });
993
+ } catch (err) {
994
+ console.error(err.message);
995
+ process.exit(1);
996
+ }
856
997
  }
857
998
 
858
999
  const newTicket = spawnDeferredChild({
@@ -864,6 +1005,7 @@ async function main() {
864
1005
  tgMode,
865
1006
  flushMode,
866
1007
  targetSessionId,
1008
+ targetSessionIds: broadcastIds,
867
1009
  deferSeconds,
868
1010
  });
869
1011
 
@@ -938,15 +1080,20 @@ async function main() {
938
1080
  }
939
1081
 
940
1082
  // Regular prompt: submit via a detached child and wait for the answer.
941
- let targetSessionId;
942
- try {
943
- targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
944
- directory: projectDir,
945
- preferredSession,
946
- });
947
- } catch (err) {
948
- console.error(err.message);
949
- process.exit(1);
1083
+ const broadcastIds = (ctx && ctx.state && Array.isArray(ctx.state.broadcast) && ctx.state.broadcast.length >= 2)
1084
+ ? ctx.state.broadcast
1085
+ : null;
1086
+ let targetSessionId = null;
1087
+ if (!broadcastIds) {
1088
+ try {
1089
+ targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
1090
+ directory: projectDir,
1091
+ preferredSession,
1092
+ });
1093
+ } catch (err) {
1094
+ console.error(err.message);
1095
+ process.exit(1);
1096
+ }
950
1097
  }
951
1098
 
952
1099
  const newTicket = spawnDeferredChild({
@@ -958,6 +1105,7 @@ async function main() {
958
1105
  tgMode,
959
1106
  flushMode,
960
1107
  targetSessionId,
1108
+ targetSessionIds: broadcastIds,
961
1109
  deferSeconds: 0,
962
1110
  });
963
1111
 
@@ -993,34 +1141,62 @@ async function main() {
993
1141
  // Wrap main processing in try-catch for deferChildMode to write errors to output file
994
1142
  let tgError = null;
995
1143
  async function runMain() {
996
- const sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession, {
997
- directory: projectDir,
998
- preferredSession,
999
- });
1144
+ // Broadcast mode: ocmux's Space key selected multiple sessions in
1145
+ // .ocmux.json — send the prompt to all of them.
1146
+ const broadcastIds = (ctx && ctx.state && Array.isArray(ctx.state.broadcast) && ctx.state.broadcast.length >= 2)
1147
+ ? ctx.state.broadcast
1148
+ : null;
1000
1149
 
1001
- // v2: focus the target project's TUI window so the user sees the prompt
1002
- // stream, and prepend the session's annotation (if any) to the prompt.
1003
- let prompt = actualPromptText;
1004
- try {
1005
- if (ctx && ctx.statefile) {
1006
- const ann = projectState.readAnnotations(ctx.statefile)[sessionId];
1007
- if (ann) prompt = ann + '\n\n' + actualPromptText;
1150
+ let sessionId = null;
1151
+ let answer;
1152
+ if (broadcastIds) {
1153
+ const bres = await runBroadcastSend(serverBase, broadcastIds, actualPromptText, {
1154
+ getActiveSessions,
1155
+ getSession,
1156
+ sendToSession,
1157
+ getPrompt: (id, text) => {
1158
+ try {
1159
+ const ann = ctx && ctx.statefile && projectState.readAnnotations(ctx.statefile)[id];
1160
+ return ann ? ann + '\n\n' + text : text;
1161
+ } catch {
1162
+ return text;
1163
+ }
1164
+ },
1165
+ readBroadcast: () => (ctx && ctx.statefile ? projectState.readProjectState(ctx.statefile).broadcast : null),
1166
+ });
1167
+ answer = formatBroadcastResults(bres);
1168
+ } else {
1169
+ sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession, {
1170
+ directory: projectDir,
1171
+ preferredSession,
1172
+ });
1173
+
1174
+ // Prepend the session's annotation (if any) to the prompt. TUI routing is
1175
+ // owned by explicit ocmux session switches, not by prompt submission.
1176
+ let prompt = actualPromptText;
1177
+ try {
1178
+ if (ctx && ctx.statefile) {
1179
+ const ann = projectState.readAnnotations(ctx.statefile)[sessionId];
1180
+ if (ann) prompt = ann + '\n\n' + actualPromptText;
1181
+ }
1182
+ } catch {
1183
+ // Annotation lookup is best-effort.
1008
1184
  }
1009
- if (projectDir) ocmuxLib.focusWindowByDir(projectDir);
1010
- } catch {
1011
- // tmux focus is best-effort; never fail the prompt because of it
1012
- }
1013
1185
 
1014
- let answer;
1015
- try {
1016
- answer = await sendToSession(serverBase, sessionId, prompt);
1017
- } catch (err) {
1018
- throw new Error(`Error sending prompt: ${err.message}`);
1186
+ try {
1187
+ answer = await sendToSession(serverBase, sessionId, prompt);
1188
+ } catch (err) {
1189
+ throw new Error(`Error sending prompt: ${err.message}`);
1190
+ }
1019
1191
  }
1020
1192
 
1021
1193
  // Build the message to send to Telegram (QA pair with rulers)
1022
1194
  let tgMessage = answer;
1023
- if (qaMode) {
1195
+ if (qaMode && broadcastIds) {
1196
+ tgMessage = HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
1197
+ if (!answer.endsWith('\n')) tgMessage += '\n';
1198
+ tgMessage += FINAL_ROW;
1199
+ } else if (qaMode && !broadcastIds) {
1024
1200
  let header = '';
1025
1201
  try { header = await buildSessionHeader(serverBase, sessionId); } catch {}
1026
1202
  tgMessage = header + HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
@@ -1105,4 +1281,4 @@ if (require.main === module) {
1105
1281
  });
1106
1282
  }
1107
1283
 
1108
- module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult, waitForDeferredResultForever };
1284
+ module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult, waitForDeferredResultForever, runBroadcastSend, buildBroadcastSection, formatBroadcastResults, isBroadcastActive };