agentp 1.14.0 → 2.0.1

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
@@ -13,9 +13,15 @@ const {
13
13
  sendToSessionAsync,
14
14
  listSessions,
15
15
  createSession,
16
+ createSessionWithModel,
16
17
  getSession,
17
18
  selectSession,
19
+ sortSessionsByRecency,
20
+ interruptSession,
21
+ getActiveSessions,
18
22
  } = require('../lib/opencode');
23
+ const projectState = require('../lib/project-state');
24
+ const ocmuxLib = require('../lib/ocmux');
19
25
 
20
26
  const TGAGENTP_PORT_FILE = '/tmp/tgagentp-port';
21
27
 
@@ -163,14 +169,19 @@ function parseDeferredTicketInput(text) {
163
169
  if (!data || typeof data.path !== 'string' || !data.path) return null;
164
170
  const rest = afterPrefix.slice(jsonEnd);
165
171
  const followupText = rest.trim() ? stripFollowupRow(rest.trimStart()) : '';
166
- return {
172
+ const ticket = {
167
173
  ctime: typeof data.ctime === 'string' ? data.ctime : null,
168
174
  path: data.path,
169
175
  defer: typeof data.defer === 'number' && data.defer > 0 ? data.defer : 0,
170
176
  server: typeof data.server === 'string' ? data.server : null,
171
177
  sessionId: typeof data.sessionId === 'string' ? data.sessionId : null,
178
+ cancelled: data.cancelled === true,
172
179
  followupText,
173
180
  };
181
+ if (Array.isArray(data.sessionIds)) {
182
+ ticket.sessionIds = data.sessionIds.filter(x => typeof x === 'string' && x);
183
+ }
184
+ return ticket;
174
185
  }
175
186
 
176
187
  function parseDeferredTicket(text) {
@@ -187,20 +198,58 @@ function formatTicket(ticket, elapsed, compact) {
187
198
  const fields = { ctime: ticket.ctime, path: ticket.path };
188
199
  if (ticket.server) fields.server = ticket.server;
189
200
  if (ticket.sessionId) fields.sessionId = ticket.sessionId;
201
+ if (Array.isArray(ticket.sessionIds) && ticket.sessionIds.length > 0) fields.sessionIds = ticket.sessionIds;
190
202
  if (elapsed !== undefined) fields.elapsed = elapsed;
191
203
  if (ticket.defer > 0) fields.defer = ticket.defer;
204
+ // Always present so it can be flipped to true and piped back to cancel.
205
+ fields.cancelled = ticket.cancelled === true;
192
206
  return 'agentp_ticket ' + (compact ? JSON.stringify(fields) : JSON.stringify(fields, null, 2));
193
207
  }
194
208
 
195
- async function resolveTargetSession(serverBase, sessionName, newSession) {
209
+ // Cancel a deferred ticket: interrupt the session (ESC-equivalent) and discard
210
+ // the ticket. Prints a message and exits 0.
211
+ async function cancelTicket(ticket) {
212
+ let interrupted = false;
213
+ const ids = Array.isArray(ticket.sessionIds) && ticket.sessionIds.length > 0
214
+ ? ticket.sessionIds
215
+ : (ticket.sessionId ? [ticket.sessionId] : []);
216
+ if (ticket.server && ids.length > 0) {
217
+ const interruptedAll = await Promise.all(ids.map(id => interruptSession(ticket.server, id)));
218
+ interrupted = interruptedAll.some(Boolean);
219
+ }
220
+ try { fs.unlinkSync(ticket.path); } catch {}
221
+ try { fs.unlinkSync(ticket.path + '.lock'); } catch {}
222
+ removeQueuedFollowups(ticket.path);
223
+ process.stdout.write(`\n🚫 Prompt cancelled${interrupted ? '' : ' (or already finished)'}.\n`);
224
+ process.exit(0);
225
+ }
226
+
227
+ // Print a deferred ticket (with elapsed) and exit — used for the interrupt path.
228
+ function printTicketAndExit(ticket, onlineTicket) {
229
+ const elapsed = ticket && ticket.ctime
230
+ ? Math.max(0, Math.floor((Date.now() - Date.parse(ticket.ctime)) / 1000))
231
+ : 0;
232
+ process.stdout.write(formatTicket(ticket, elapsed, onlineTicket) + '\n');
233
+ process.exit(0);
234
+ }
235
+
236
+ async function resolveTargetSession(serverBase, sessionName, newSession, opts = {}) {
237
+ // opts: { directory, preferredSession } — v2 project/session resolution from
238
+ // the nearest .ocmux.json. `directory` scopes the listing to the project;
239
+ // `preferredSession` is a stored session ID validated against that listing.
240
+ const directory = opts.directory || undefined;
241
+ const preferredSession = opts.preferredSession || null;
242
+
196
243
  let sessions;
197
244
  try {
198
- sessions = await listSessions(serverBase);
245
+ sessions = await listSessions(serverBase, directory);
199
246
  } catch (err) {
200
247
  throw new Error(`Error connecting to server: ${err.message}`);
201
248
  }
202
249
 
203
250
  if (sessionName) {
251
+ const byId = sessions.find(s => String(s.id) === sessionName);
252
+ if (byId) return byId.id;
204
253
  const matches = sessions.filter(s => {
205
254
  const title = s.title || s.slug || s.id;
206
255
  return title.toLowerCase() === sessionName.toLowerCase();
@@ -214,7 +263,7 @@ async function resolveTargetSession(serverBase, sessionName, newSession) {
214
263
  if (partial.length > 1) throw new Error(`Error: multiple sessions match "${sessionName}"`);
215
264
  if (!newSession) throw new Error(`Error: no session found matching "${sessionName}"`);
216
265
  try {
217
- const session = await createSession(serverBase, sessionName);
266
+ const session = await createSessionWithModel(serverBase, sessionName, directory, sessions);
218
267
  await selectSession(serverBase, session.id);
219
268
  return session.id;
220
269
  } catch (err) {
@@ -224,19 +273,20 @@ async function resolveTargetSession(serverBase, sessionName, newSession) {
224
273
  return matches[0].id;
225
274
  }
226
275
 
276
+ // Prefer the session recorded in .ocmux.json when it still exists.
277
+ if (preferredSession && sessions.some(s => String(s.id) === String(preferredSession))) {
278
+ return preferredSession;
279
+ }
280
+
227
281
  if (sessions && sessions.length > 0) {
228
- const sorted = sessions
229
- .filter(s => s.time && (s.time.updated || s.time.created))
230
- .sort((a, b) => {
231
- const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
232
- const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
233
- return bTime - aTime;
234
- });
282
+ // Only "main" sessions (no parentID) are considered as implicit targets —
283
+ // child/subagent sessions are work products, not conversation targets.
284
+ const sorted = sortSessionsByRecency(sessions).filter(s => s.id && !s.parentID);
235
285
  if (sorted.length > 0) return sorted[0].id;
236
286
  }
237
287
 
238
288
  try {
239
- const session = await createSession(serverBase, 'agentp');
289
+ const session = await createSessionWithModel(serverBase, 'agentp', directory, sessions);
240
290
  return session.id;
241
291
  } catch (err) {
242
292
  throw new Error(`Error creating session: ${err.message}`);
@@ -273,6 +323,41 @@ function sleepMs(ms) {
273
323
  return new Promise((resolve) => setTimeout(resolve, ms));
274
324
  }
275
325
 
326
+ // Wait indefinitely for a deferred result. Resolves with `{ result }` once the
327
+ // answer is ready, or `{ interrupted: true }` if SIGINT is received while
328
+ // waiting (any detached child keeps running). The caller decides how to react
329
+ // to the interrupt (normally print a ticket and exit).
330
+ function waitForDeferredResultForever(filePath) {
331
+ return new Promise((resolve) => {
332
+ let timer = null;
333
+ let done = false;
334
+
335
+ const finish = (value) => {
336
+ if (done) return;
337
+ done = true;
338
+ if (timer) clearTimeout(timer);
339
+ process.removeListener('SIGINT', onSigint);
340
+ resolve(value);
341
+ };
342
+
343
+ function onSigint() {
344
+ finish({ interrupted: true });
345
+ }
346
+
347
+ const poll = () => {
348
+ const result = readDeferredResult(filePath);
349
+ if (result !== null) {
350
+ finish({ result });
351
+ return;
352
+ }
353
+ timer = setTimeout(poll, DEFER_POLL_MS);
354
+ };
355
+
356
+ process.on('SIGINT', onSigint);
357
+ poll();
358
+ });
359
+ }
360
+
276
361
  function appendUnusedFollowup(result, followupText) {
277
362
  if (!followupText) return result;
278
363
  return result + (result.endsWith('\n') ? '' : '\n') + followupText;
@@ -313,10 +398,166 @@ function injectQueuedFollowups(result, followups) {
313
398
  return before + (before.endsWith('\n') ? '' : '\n') + addition + after;
314
399
  }
315
400
 
316
- function formatDeferredResult(filePath, result, qaMode, unusedFollowupText) {
401
+ function formatDeferredResult(filePath, result, qaMode, unusedFollowupText, header) {
317
402
  const followups = qaMode ? readQueuedFollowups(filePath) : [];
318
403
  const output = qaMode ? injectQueuedFollowups(result, followups) : result;
319
- return appendUnusedFollowup(output, unusedFollowupText);
404
+ // The stored answer (produced by the detached child) may already carry the
405
+ // project/session header — don't prepend it a second time on retrieval.
406
+ const withHeader = qaMode && header && !carriesHeader(output) ? header + output : output;
407
+ return appendUnusedFollowup(withHeader, unusedFollowupText);
408
+ }
409
+
410
+ // Does the text already begin with the šŸ“‚ / šŸ’¬ header lines?
411
+ function carriesHeader(text) {
412
+ return /^(?:\u{1F4C2}|\u{1F4AC})/u.test(String(text || '').trimStart());
413
+ }
414
+
415
+ // Output a ready deferred result, clean up its temp files, and exit 0.
416
+ function outputReadyResult(filePath, result, qaMode, unusedFollowupText, header) {
417
+ try {
418
+ fs.unlinkSync(filePath);
419
+ } catch (err) {
420
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
421
+ process.exit(1);
422
+ }
423
+ try { fs.unlinkSync(filePath + '.lock'); } catch {}
424
+ process.stdout.write(formatDeferredResult(filePath, result, qaMode, unusedFollowupText, header));
425
+ removeQueuedFollowups(filePath);
426
+ process.exit(0);
427
+ }
428
+
429
+ // ── Broadcast (ocmux "Space" multi-session send) ───────────────────
430
+
431
+ // One answer section: a heading with the session name and a separator below it.
432
+ function buildBroadcastSection(title, id, answer) {
433
+ let s = `\nšŸ’¬ ${title} [${id}]\n${'━'.repeat(40)}\n`;
434
+ s += answer;
435
+ if (!answer.endsWith('\n')) s += '\n';
436
+ return s;
437
+ }
438
+
439
+ // Send a prompt to every session in `ids` and collect the answers.
440
+ // deps.getActiveSessions(server) -> Set of busy session ids
441
+ // deps.getSession(server, id) -> session object (title)
442
+ // deps.sendToSession(server, id, text) -> answer text
443
+ // deps.readBroadcast() -> current .ocmux.json broadcast list (null/[] = cancelled)
444
+ // Waits for each session to become idle before sending; stops as soon as the
445
+ // broadcast list disappears (ocmux exited broadcast mode early).
446
+ async function runBroadcastSend(server, ids, prompt, deps) {
447
+ const { getActiveSessions, getSession, sendToSession, readBroadcast, getPrompt, pollMs = 1000, busyTimeoutMs = 15 * 60 * 1000 } = deps;
448
+ const initial = new Set(ids.map(String));
449
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
450
+ const cancelled = () => {
451
+ try { return !isBroadcastActive(readBroadcast(), initial); } catch { return false; }
452
+ };
453
+
454
+ // Race a sent session's answer against ocmux leaving broadcast mode. We do
455
+ // not interrupt the model: we merely stop waiting/discard its later answer,
456
+ // exactly as the UI contract promises.
457
+ const awaitAnswerOrCancel = (promise) => new Promise((resolve) => {
458
+ let done = false;
459
+ const finish = (value) => {
460
+ if (done) return;
461
+ done = true;
462
+ clearInterval(timer);
463
+ resolve(value);
464
+ };
465
+ const timer = setInterval(() => {
466
+ if (cancelled()) finish({ cancelled: true });
467
+ }, pollMs);
468
+ promise.then(answer => finish({ answer }), err => finish({ error: err }));
469
+ });
470
+
471
+ const runOne = async (id) => {
472
+ let title = id;
473
+ try {
474
+ const s = await getSession(server, id);
475
+ title = (s && (s.title || s.id)) || id;
476
+ } catch {
477
+ return { missing: { id, title, error: 'Unable to read session', timestamp: new Date().toISOString() } };
478
+ }
479
+ // Wait for the session to become idle (skip prompts into a busy session).
480
+ const deadline = Date.now() + busyTimeoutMs;
481
+ let busy = true;
482
+ while (busy && Date.now() < deadline) {
483
+ if (cancelled()) {
484
+ return { missing: { id, title, error: 'Broadcast cancelled while waiting for idle', timestamp: new Date().toISOString() } };
485
+ }
486
+ const active = await getActiveSessions(server);
487
+ if (!active.has(String(id))) busy = false;
488
+ else await sleep(pollMs);
489
+ }
490
+ if (busy) {
491
+ return { missing: { id, title, error: 'Session stayed busy until timeout', timestamp: new Date().toISOString() } };
492
+ }
493
+ try {
494
+ const result = await awaitAnswerOrCancel(sendToSession(server, id, getPrompt ? getPrompt(id, prompt) : prompt));
495
+ if (result.cancelled) {
496
+ return { missing: { id, title, error: 'Broadcast cancelled before this session replied', timestamp: new Date().toISOString() } };
497
+ }
498
+ if (result.error) {
499
+ return { missing: { id, title, error: result.error.message || String(result.error), timestamp: new Date().toISOString() } };
500
+ }
501
+ return { result: { id, title, answer: result.answer } };
502
+ } catch (err) {
503
+ return { missing: { id, title, error: err.message || String(err), timestamp: new Date().toISOString() } };
504
+ }
505
+ };
506
+
507
+ // Run independently so one busy/model-limited session never prevents all
508
+ // other selected sessions from receiving the prompt.
509
+ const settled = await Promise.all(ids.map(runOne));
510
+ const results = settled.filter(x => x.result).map(x => x.result);
511
+ const missing = settled.filter(x => x.missing).map(x => x.missing);
512
+ return { results, missing, requested: ids.length };
513
+ }
514
+
515
+ // A broadcast list is still active while it is an array that contains every
516
+ // session we were asked to reach.
517
+ function isBroadcastActive(broadcast, initial) {
518
+ return Array.isArray(broadcast)
519
+ && broadcast.length >= 2
520
+ && [...initial].every((id) => broadcast.includes(id));
521
+ }
522
+
523
+ // Assemble the final broadcast output: heading per session + missing note.
524
+ function formatBroadcastResults({ results, missing, requested }) {
525
+ let out = `🌐 Broadcast to ${requested} sessions\n`;
526
+ for (const r of results) out += buildBroadcastSection(r.title, r.id, r.answer);
527
+ if (missing.length > 0) {
528
+ out += '\nāš ļø Prompt not completed in the following sessions:\n';
529
+ for (const m of missing) {
530
+ out += `- šŸ’¬ ${m.title} [${m.id}]\n - Error: ${m.error || 'Unknown'}\n`;
531
+ if (m.timestamp) out += ` - At: ${m.timestamp}\n`;
532
+ }
533
+ }
534
+ return out;
535
+ }
536
+
537
+ // Structured header (project path + session id/title) prepended to --qa output,
538
+ // so a piped answer is self-describing about where it came from.
539
+ async function buildSessionHeader(server, sessionId) {
540
+ let title = sessionId;
541
+ let dir = '';
542
+ try {
543
+ const s = await getSession(server, sessionId);
544
+ if (s && (s.title || s.id)) title = s.title || s.id;
545
+ if (s && s.location && s.location.directory) dir = s.location.directory;
546
+ } catch {
547
+ // keep the plain id form
548
+ }
549
+ let out = '';
550
+ if (dir) out += `šŸ“‚ ${dir}\n`;
551
+ out += `šŸ’¬ ${title} [${sessionId}]\n`;
552
+ return out;
553
+ }
554
+
555
+ // Header for deferred-ticket retrieval (ticket carries server + sessionId).
556
+ async function ticketHeader(ticket, qaMode) {
557
+ if (qaMode && ticket && ticket.server && ticket.sessionId && !(Array.isArray(ticket.sessionIds) && ticket.sessionIds.length >= 2)) {
558
+ try { return await buildSessionHeader(ticket.server, ticket.sessionId); } catch {}
559
+ }
560
+ return '';
320
561
  }
321
562
 
322
563
  async function readAllStdin() {
@@ -333,18 +574,79 @@ const HUMAN_ROW = 'šŸ‘¤: —————————————————\n'
333
574
  const AGENT_ROW = 'šŸ¤–: —————————————————\n'
334
575
  const FINAL_ROW = ' —————————————————\n';
335
576
 
577
+ // Write the prompt to a temp file, lock the output file, and spawn a detached
578
+ // `--defer-child` process that runs the prompt and writes the answer. Returns
579
+ // the ticket describing this deferred run.
580
+ function spawnDeferredChild({ promptText, serverBase, sessionName, newSession, qaMode, tgMode, flushMode, targetSessionId, targetSessionIds, deferSeconds }) {
581
+ const tempPath = generateTempFilename();
582
+ const promptTempPath = tempPath + '.prompt';
583
+
584
+ fs.writeFileSync(promptTempPath, promptText);
585
+ fs.writeFileSync(tempPath, '');
586
+ lockFile(tempPath);
587
+
588
+ const ticket = {
589
+ ctime: new Date().toISOString(),
590
+ path: tempPath,
591
+ defer: deferSeconds || 0,
592
+ server: serverBase,
593
+ sessionId: targetSessionId,
594
+ sessionIds: targetSessionIds,
595
+ cancelled: false,
596
+ };
597
+
598
+ const childArgs = [
599
+ __filename,
600
+ '--defer-child',
601
+ '--prompt-file', promptTempPath,
602
+ '--output-file', tempPath,
603
+ ];
604
+ if (qaMode) childArgs.push('--qa');
605
+ if (tgMode === true) childArgs.push('--tg');
606
+ if (tgMode === false) childArgs.push('--no-tg');
607
+ if (flushMode) childArgs.push('--flush');
608
+ if (sessionName) {
609
+ childArgs.push('--session', sessionName);
610
+ if (newSession) childArgs.push('--new');
611
+ }
612
+ if (targetSessionId) childArgs.push('--resolved-session-id', targetSessionId);
613
+ if (Array.isArray(targetSessionIds) && targetSessionIds.length > 0) {
614
+ childArgs.push('--resolved-session-ids', JSON.stringify(targetSessionIds));
615
+ }
616
+ childArgs.push(serverBase);
617
+
618
+ const child = child_process.spawn(process.execPath, childArgs, {
619
+ detached: true,
620
+ stdio: 'ignore',
621
+ });
622
+ child.unref();
623
+
624
+ return ticket;
625
+ }
626
+
336
627
  async function main() {
337
628
  let serverBase = 'http://localhost:4096';
338
629
  let qaMode = false;
339
630
  let flushMode = false;
340
631
  let deferMode = false;
341
632
  let deferSeconds = 0;
633
+ // Project context from the nearest .ocmux.json (v2 model): project directory,
634
+ // stored session ID, and cached server URL. All null when no state file
635
+ // applies, in which case agentp keeps its legacy (unscoped) behavior.
636
+ const ctx = projectState.resolveContext();
637
+ const projectDir = ctx.directory;
638
+ const stateSession = ctx.session;
639
+ // Adopt the project's cached server (from .ocmux.json) unless an explicit
640
+ // server/port is given later in the argument loop (--getLast runs mid-loop,
641
+ // so adoption must happen before it).
642
+ if (ctx.server) serverBase = ctx.server;
342
643
  let deferChildMode = false;
343
644
  let onlineTicket = false;
344
645
  let promptFile = null;
345
646
  let outputFile = null;
346
647
  let sessionName = null;
347
648
  let resolvedSessionId = null;
649
+ let resolvedSessionIds = null;
348
650
  let newSession = false;
349
651
  // tgMode: false = disabled, 'auto' = auto-detect, true = explicit --tg
350
652
  let tgMode = null;
@@ -373,6 +675,7 @@ async function main() {
373
675
  console.error(' --getLast Retrieve last N assistant answers (or QA pairs with --qa) from session history');
374
676
  console.error(' --session Target a specific session by name (creates it if not found with --new)');
375
677
  console.error(' --new Create a new session with the given title (requires --session)');
678
+ console.error(' --server OpenCode server URL (overrides .ocmux.json); e.g. http://host:4096');
376
679
  console.error(' --help Show this help message');
377
680
  console.error('');
378
681
  console.error('By default, --qa auto-detects tgagentp; standalone mode implies --no-tg.');
@@ -391,8 +694,9 @@ async function main() {
391
694
  console.error('');
392
695
  console.error('Arguments:');
393
696
  console.error(' url OpenCode server URL or port number (default: 4096)');
394
- console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
395
- console.error(' Tip: $(ocmux) expands to the URL of the current project\'s server.');
697
+ console.error(' Example: 4096, http://localhost:4096, http://192.168.1.50:4096');
698
+ console.error(' Omit it to resolve from the nearest .ocmux.json');
699
+ console.error(' (its directory selects the project, its session id the target).');
396
700
  console.error('');
397
701
  console.error('See also: tgagentp -- Telegram bridge (tgagentp --help), ocmux -- manage OpenCode tmux sessions (ocmux --help)');
398
702
  console.error('');
@@ -445,6 +749,16 @@ async function main() {
445
749
  console.error('Error: --resolved-session-id requires a session id');
446
750
  process.exit(1);
447
751
  }
752
+ } else if (args[i] === '--resolved-session-ids') {
753
+ i++;
754
+ try {
755
+ const ids = JSON.parse(args[i]);
756
+ if (!Array.isArray(ids) || ids.length < 2 || ids.some(id => typeof id !== 'string' || !id)) throw new Error('bad ids');
757
+ resolvedSessionIds = ids;
758
+ } catch {
759
+ console.error('Error: --resolved-session-ids requires a JSON array of at least two session ids');
760
+ process.exit(1);
761
+ }
448
762
  } else if (args[i] === '--new') {
449
763
  newSession = true;
450
764
  } else if (args[i] === '--getLast') {
@@ -456,18 +770,12 @@ async function main() {
456
770
  }
457
771
  const n = parseInt(nStr, 10);
458
772
  try {
459
- const sessions = await listSessions(serverBase);
773
+ const sessions = await listSessions(serverBase, projectDir || undefined);
460
774
  if (!sessions || sessions.length === 0) {
461
775
  console.error('No sessions found.');
462
776
  process.exit(1);
463
777
  }
464
- const sorted = sessions
465
- .filter(s => s.time && (s.time.updated || s.time.created))
466
- .sort((a, b) => {
467
- const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
468
- const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
469
- return bTime - aTime;
470
- });
778
+ const sorted = sortSessionsByRecency(sessions);
471
779
  const sessionId = sorted[0].id;
472
780
 
473
781
  function buildPairsFromMessages(messages) {
@@ -548,6 +856,13 @@ async function main() {
548
856
  tgMode = true;
549
857
  } else if (args[i] === '--no-tg') {
550
858
  tgMode = false;
859
+ } else if (args[i] === '--server') {
860
+ i++;
861
+ if (!args[i]) {
862
+ console.error("Error: '--server' requires a URL (e.g. http://host:4096)");
863
+ process.exit(1);
864
+ }
865
+ serverBase = args[i].replace(/\/+$/, '');
551
866
  } else if (args[i].includes('://')) {
552
867
  serverBase = args[i].replace(/\/+$/, '');
553
868
  } else if (/^\d+$/.test(args[i])) {
@@ -563,6 +878,8 @@ async function main() {
563
878
  }
564
879
  }
565
880
 
881
+ const preferredSession = (sessionName || newSession || resolvedSessionId) ? null : stateSession;
882
+
566
883
  // Resolve default: --qa → auto-detect, standalone → disabled
567
884
  if (tgMode === null) {
568
885
  tgMode = qaMode ? 'auto' : false;
@@ -596,6 +913,10 @@ async function main() {
596
913
  // ignored; the ticket's own "defer" timeout is used instead (default 0).
597
914
  const deferredPath = ticket.path;
598
915
 
916
+ if (ticket.cancelled) {
917
+ await cancelTicket(ticket);
918
+ }
919
+
599
920
  if (!fs.existsSync(deferredPath)) {
600
921
  process.stdout.write(`Error: deferred file not found: ${deferredPath}\n`);
601
922
  process.exit(1);
@@ -614,7 +935,7 @@ async function main() {
614
935
  process.exit(1);
615
936
  }
616
937
  try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
617
- process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ticket.followupText));
938
+ process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ticket.followupText, await ticketHeader(ticket, qaMode)));
618
939
  removeQueuedFollowups(deferredPath);
619
940
  process.exit(0);
620
941
  }
@@ -643,7 +964,7 @@ async function main() {
643
964
  process.exit(1);
644
965
  }
645
966
  try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
646
- process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ''));
967
+ process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, '', await ticketHeader(ticket, qaMode)));
647
968
  removeQueuedFollowups(deferredPath);
648
969
  process.exit(0);
649
970
  }
@@ -658,70 +979,49 @@ async function main() {
658
979
  }
659
980
 
660
981
  // This is a regular prompt — create deferred execution
661
- const tempPath = generateTempFilename();
662
- const promptTempPath = tempPath + '.prompt';
663
-
664
- let targetSessionId;
665
- try {
666
- targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession);
667
- } catch (err) {
668
- console.error(err.message);
669
- process.exit(1);
670
- }
671
-
672
- // Write prompt to temp file for child process
673
- fs.writeFileSync(promptTempPath, promptText);
674
-
675
- // Create and lock the output file
676
- fs.writeFileSync(tempPath, '');
677
- lockFile(tempPath);
678
-
679
- const newTicket = {
680
- ctime: new Date().toISOString(),
681
- path: tempPath,
682
- defer: deferSeconds,
683
- server: serverBase,
684
- sessionId: targetSessionId,
685
- };
686
-
687
- // Spawn detached child process
688
- const childArgs = [
689
- __filename,
690
- '--defer-child',
691
- '--prompt-file', promptTempPath,
692
- '--output-file', tempPath,
693
- ];
694
-
695
- // Pass through other flags
696
- if (qaMode) childArgs.push('--qa');
697
- if (tgMode === true) childArgs.push('--tg');
698
- if (tgMode === false) childArgs.push('--no-tg');
699
- if (flushMode) childArgs.push('--flush');
700
- if (sessionName) {
701
- childArgs.push('--session', sessionName);
702
- if (newSession) childArgs.push('--new');
982
+ const broadcastIds = (resolvedSessionIds && resolvedSessionIds.length >= 2)
983
+ ? resolvedSessionIds
984
+ : ((ctx && ctx.state && Array.isArray(ctx.state.broadcast) && ctx.state.broadcast.length >= 2)
985
+ ? ctx.state.broadcast
986
+ : null);
987
+ let targetSessionId = null;
988
+ if (!broadcastIds) {
989
+ try {
990
+ targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
991
+ directory: projectDir,
992
+ preferredSession,
993
+ });
994
+ } catch (err) {
995
+ console.error(err.message);
996
+ process.exit(1);
997
+ }
703
998
  }
704
- childArgs.push('--resolved-session-id', targetSessionId);
705
- childArgs.push(serverBase);
706
999
 
707
- const child = child_process.spawn(process.execPath, childArgs, {
708
- detached: true,
709
- stdio: 'ignore',
1000
+ const newTicket = spawnDeferredChild({
1001
+ promptText,
1002
+ serverBase,
1003
+ sessionName,
1004
+ newSession,
1005
+ qaMode,
1006
+ tgMode,
1007
+ flushMode,
1008
+ targetSessionId,
1009
+ targetSessionIds: broadcastIds,
1010
+ deferSeconds,
710
1011
  });
711
- child.unref();
712
1012
 
713
1013
  // If a timeout was given, block until the answer arrives or the timeout
714
1014
  // expires. Return the response when it arrives; otherwise print a ticket.
715
1015
  if (deferSeconds > 0) {
716
- const result = await waitForDeferredResult(tempPath, deferSeconds * 1000);
1016
+ const result = await waitForDeferredResult(newTicket.path, deferSeconds * 1000);
717
1017
  if (result !== null) {
718
1018
  try {
719
- fs.unlinkSync(tempPath);
1019
+ fs.unlinkSync(newTicket.path);
720
1020
  } catch (err) {
721
1021
  process.stdout.write(`Error reading deferred file: ${err.message}\n`);
722
1022
  process.exit(1);
723
1023
  }
724
- try { fs.unlinkSync(tempPath + '.lock'); } catch {}
1024
+ try { fs.unlinkSync(newTicket.path + '.lock'); } catch {}
725
1025
  process.stdout.write(result);
726
1026
  process.exit(0);
727
1027
  }
@@ -732,6 +1032,99 @@ async function main() {
732
1032
  process.exit(0);
733
1033
  }
734
1034
 
1035
+ // Normal (non-deferred, non-child) mode: wait for the answer, but run it in a
1036
+ // detached child so a Ctrl+C can hand the in-flight response off as a ticket.
1037
+ if (!deferChildMode) {
1038
+ const ticket = parseDeferredTicketInput(promptText);
1039
+
1040
+ if (ticket) {
1041
+ // Recognize a deferred ticket: wait indefinitely for the answer, and
1042
+ // re-print the ticket only if the user interrupts with Ctrl+C.
1043
+ const deferredPath = ticket.path;
1044
+
1045
+ if (ticket.cancelled) {
1046
+ await cancelTicket(ticket);
1047
+ }
1048
+
1049
+ if (!fs.existsSync(deferredPath)) {
1050
+ process.stdout.write(`Error: deferred file not found: ${deferredPath}\n`);
1051
+ process.exit(1);
1052
+ }
1053
+
1054
+ let unusedFollowup = ticket.followupText || '';
1055
+
1056
+ // If the answer is already ready, return it immediately.
1057
+ let result = ticket.followupText ? readDeferredResult(deferredPath) : null;
1058
+ if (result !== null) {
1059
+ outputReadyResult(deferredPath, result, qaMode, unusedFollowup, await ticketHeader(ticket, qaMode));
1060
+ }
1061
+
1062
+ // Queue any follow-up text to the original session (same as --defer).
1063
+ if (ticket.followupText) {
1064
+ if (!ticket.server || !ticket.sessionId) {
1065
+ process.stdout.write('Error: deferred ticket does not include server/sessionId; cannot queue follow-up text. Re-submit only the ticket to check the result.\n');
1066
+ process.exit(1);
1067
+ }
1068
+ try {
1069
+ await sendToSessionAsync(ticket.server, ticket.sessionId, ticket.followupText);
1070
+ appendQueuedFollowup(deferredPath, ticket.followupText);
1071
+ unusedFollowup = ''; // follow-up already queued; don't echo it again
1072
+ } catch (err) {
1073
+ process.stdout.write(`Error queueing follow-up prompt: ${err.message}\n`);
1074
+ process.exit(1);
1075
+ }
1076
+ }
1077
+
1078
+ const wait = await waitForDeferredResultForever(deferredPath);
1079
+ if (wait.interrupted) printTicketAndExit(ticket, onlineTicket);
1080
+ outputReadyResult(deferredPath, wait.result, qaMode, unusedFollowup, await ticketHeader(ticket, qaMode));
1081
+ }
1082
+
1083
+ // Regular prompt: submit via a detached child and wait for the answer.
1084
+ const broadcastIds = (ctx && ctx.state && Array.isArray(ctx.state.broadcast) && ctx.state.broadcast.length >= 2)
1085
+ ? ctx.state.broadcast
1086
+ : null;
1087
+ let targetSessionId = null;
1088
+ if (!broadcastIds) {
1089
+ try {
1090
+ targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
1091
+ directory: projectDir,
1092
+ preferredSession,
1093
+ });
1094
+ } catch (err) {
1095
+ console.error(err.message);
1096
+ process.exit(1);
1097
+ }
1098
+ }
1099
+
1100
+ const newTicket = spawnDeferredChild({
1101
+ promptText,
1102
+ serverBase,
1103
+ sessionName,
1104
+ newSession,
1105
+ qaMode,
1106
+ tgMode,
1107
+ flushMode,
1108
+ targetSessionId,
1109
+ targetSessionIds: broadcastIds,
1110
+ deferSeconds: 0,
1111
+ });
1112
+
1113
+ const wait = await waitForDeferredResultForever(newTicket.path);
1114
+ if (wait.interrupted) printTicketAndExit(newTicket, onlineTicket);
1115
+
1116
+ // Answer ready — output it and clean up.
1117
+ try {
1118
+ fs.unlinkSync(newTicket.path);
1119
+ } catch (err) {
1120
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
1121
+ process.exit(1);
1122
+ }
1123
+ try { fs.unlinkSync(newTicket.path + '.lock'); } catch {}
1124
+ process.stdout.write(wait.result);
1125
+ process.exit(0);
1126
+ }
1127
+
735
1128
  // Handle --defer-child mode (child process)
736
1129
  let actualPromptText = promptText;
737
1130
  if (deferChildMode && promptFile) {
@@ -749,21 +1142,69 @@ async function main() {
749
1142
  // Wrap main processing in try-catch for deferChildMode to write errors to output file
750
1143
  let tgError = null;
751
1144
  async function runMain() {
752
- const sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession);
1145
+ // Broadcast mode: ocmux's Space key selected multiple sessions in
1146
+ // .ocmux.json — send the prompt to all of them.
1147
+ const broadcastIds = (ctx && ctx.state && Array.isArray(ctx.state.broadcast) && ctx.state.broadcast.length >= 2)
1148
+ ? ctx.state.broadcast
1149
+ : null;
753
1150
 
1151
+ let sessionId = null;
754
1152
  let answer;
755
- try {
756
- answer = await sendToSession(serverBase, sessionId, actualPromptText);
757
- } catch (err) {
758
- throw new Error(`Error sending prompt: ${err.message}`);
1153
+ if (broadcastIds) {
1154
+ try { if (projectDir) ocmuxLib.focusWindowByDir(projectDir); } catch {}
1155
+ const bres = await runBroadcastSend(serverBase, broadcastIds, actualPromptText, {
1156
+ getActiveSessions,
1157
+ getSession,
1158
+ sendToSession,
1159
+ getPrompt: (id, text) => {
1160
+ try {
1161
+ const ann = ctx && ctx.statefile && projectState.readAnnotations(ctx.statefile)[id];
1162
+ return ann ? ann + '\n\n' + text : text;
1163
+ } catch {
1164
+ return text;
1165
+ }
1166
+ },
1167
+ readBroadcast: () => (ctx && ctx.statefile ? projectState.readProjectState(ctx.statefile).broadcast : null),
1168
+ });
1169
+ answer = formatBroadcastResults(bres);
1170
+ } else {
1171
+ sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession, {
1172
+ directory: projectDir,
1173
+ preferredSession,
1174
+ });
1175
+
1176
+ // v2: focus the target project's TUI window so the user sees the prompt
1177
+ // stream, and prepend the session's annotation (if any) to the prompt.
1178
+ let prompt = actualPromptText;
1179
+ try {
1180
+ if (ctx && ctx.statefile) {
1181
+ const ann = projectState.readAnnotations(ctx.statefile)[sessionId];
1182
+ if (ann) prompt = ann + '\n\n' + actualPromptText;
1183
+ }
1184
+ if (projectDir) ocmuxLib.focusWindowByDir(projectDir);
1185
+ } catch {
1186
+ // tmux focus is best-effort; never fail the prompt because of it
1187
+ }
1188
+
1189
+ try {
1190
+ answer = await sendToSession(serverBase, sessionId, prompt);
1191
+ } catch (err) {
1192
+ throw new Error(`Error sending prompt: ${err.message}`);
1193
+ }
759
1194
  }
760
1195
 
761
1196
  // Build the message to send to Telegram (QA pair with rulers)
762
1197
  let tgMessage = answer;
763
- if (qaMode) {
1198
+ if (qaMode && broadcastIds) {
764
1199
  tgMessage = HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
765
1200
  if (!answer.endsWith('\n')) tgMessage += '\n';
766
1201
  tgMessage += FINAL_ROW;
1202
+ } else if (qaMode && !broadcastIds) {
1203
+ let header = '';
1204
+ try { header = await buildSessionHeader(serverBase, sessionId); } catch {}
1205
+ tgMessage = header + HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
1206
+ if (!answer.endsWith('\n')) tgMessage += '\n';
1207
+ tgMessage += FINAL_ROW;
767
1208
  }
768
1209
 
769
1210
  // Post-send: notify tgagentp, get recorded context back
@@ -843,4 +1284,4 @@ if (require.main === module) {
843
1284
  });
844
1285
  }
845
1286
 
846
- module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult };
1287
+ module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult, waitForDeferredResultForever, runBroadcastSend, buildBroadcastSection, formatBroadcastResults, isBroadcastActive };