agentp 1.14.0 → 2.0.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
@@ -13,9 +13,14 @@ const {
13
13
  sendToSessionAsync,
14
14
  listSessions,
15
15
  createSession,
16
+ createSessionWithModel,
16
17
  getSession,
17
18
  selectSession,
19
+ sortSessionsByRecency,
20
+ interruptSession,
18
21
  } = require('../lib/opencode');
22
+ const projectState = require('../lib/project-state');
23
+ const ocmuxLib = require('../lib/ocmux');
19
24
 
20
25
  const TGAGENTP_PORT_FILE = '/tmp/tgagentp-port';
21
26
 
@@ -169,6 +174,7 @@ function parseDeferredTicketInput(text) {
169
174
  defer: typeof data.defer === 'number' && data.defer > 0 ? data.defer : 0,
170
175
  server: typeof data.server === 'string' ? data.server : null,
171
176
  sessionId: typeof data.sessionId === 'string' ? data.sessionId : null,
177
+ cancelled: data.cancelled === true,
172
178
  followupText,
173
179
  };
174
180
  }
@@ -189,18 +195,51 @@ function formatTicket(ticket, elapsed, compact) {
189
195
  if (ticket.sessionId) fields.sessionId = ticket.sessionId;
190
196
  if (elapsed !== undefined) fields.elapsed = elapsed;
191
197
  if (ticket.defer > 0) fields.defer = ticket.defer;
198
+ // Always present so it can be flipped to true and piped back to cancel.
199
+ fields.cancelled = ticket.cancelled === true;
192
200
  return 'agentp_ticket ' + (compact ? JSON.stringify(fields) : JSON.stringify(fields, null, 2));
193
201
  }
194
202
 
195
- async function resolveTargetSession(serverBase, sessionName, newSession) {
203
+ // Cancel a deferred ticket: interrupt the session (ESC-equivalent) and discard
204
+ // the ticket. Prints a message and exits 0.
205
+ async function cancelTicket(ticket) {
206
+ let interrupted = false;
207
+ if (ticket.server && ticket.sessionId) {
208
+ interrupted = await interruptSession(ticket.server, ticket.sessionId);
209
+ }
210
+ try { fs.unlinkSync(ticket.path); } catch {}
211
+ try { fs.unlinkSync(ticket.path + '.lock'); } catch {}
212
+ removeQueuedFollowups(ticket.path);
213
+ process.stdout.write(`\n🚫 Prompt cancelled${interrupted ? '' : ' (or already finished)'}.\n`);
214
+ process.exit(0);
215
+ }
216
+
217
+ // Print a deferred ticket (with elapsed) and exit — used for the interrupt path.
218
+ function printTicketAndExit(ticket, onlineTicket) {
219
+ const elapsed = ticket && ticket.ctime
220
+ ? Math.max(0, Math.floor((Date.now() - Date.parse(ticket.ctime)) / 1000))
221
+ : 0;
222
+ process.stdout.write(formatTicket(ticket, elapsed, onlineTicket) + '\n');
223
+ process.exit(0);
224
+ }
225
+
226
+ async function resolveTargetSession(serverBase, sessionName, newSession, opts = {}) {
227
+ // opts: { directory, preferredSession } — v2 project/session resolution from
228
+ // the nearest .ocmux.json. `directory` scopes the listing to the project;
229
+ // `preferredSession` is a stored session ID validated against that listing.
230
+ const directory = opts.directory || undefined;
231
+ const preferredSession = opts.preferredSession || null;
232
+
196
233
  let sessions;
197
234
  try {
198
- sessions = await listSessions(serverBase);
235
+ sessions = await listSessions(serverBase, directory);
199
236
  } catch (err) {
200
237
  throw new Error(`Error connecting to server: ${err.message}`);
201
238
  }
202
239
 
203
240
  if (sessionName) {
241
+ const byId = sessions.find(s => String(s.id) === sessionName);
242
+ if (byId) return byId.id;
204
243
  const matches = sessions.filter(s => {
205
244
  const title = s.title || s.slug || s.id;
206
245
  return title.toLowerCase() === sessionName.toLowerCase();
@@ -214,7 +253,7 @@ async function resolveTargetSession(serverBase, sessionName, newSession) {
214
253
  if (partial.length > 1) throw new Error(`Error: multiple sessions match "${sessionName}"`);
215
254
  if (!newSession) throw new Error(`Error: no session found matching "${sessionName}"`);
216
255
  try {
217
- const session = await createSession(serverBase, sessionName);
256
+ const session = await createSessionWithModel(serverBase, sessionName, directory, sessions);
218
257
  await selectSession(serverBase, session.id);
219
258
  return session.id;
220
259
  } catch (err) {
@@ -224,19 +263,18 @@ async function resolveTargetSession(serverBase, sessionName, newSession) {
224
263
  return matches[0].id;
225
264
  }
226
265
 
266
+ // Prefer the session recorded in .ocmux.json when it still exists.
267
+ if (preferredSession && sessions.some(s => String(s.id) === String(preferredSession))) {
268
+ return preferredSession;
269
+ }
270
+
227
271
  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
- });
272
+ const sorted = sortSessionsByRecency(sessions).filter(s => s.id);
235
273
  if (sorted.length > 0) return sorted[0].id;
236
274
  }
237
275
 
238
276
  try {
239
- const session = await createSession(serverBase, 'agentp');
277
+ const session = await createSessionWithModel(serverBase, 'agentp', directory, sessions);
240
278
  return session.id;
241
279
  } catch (err) {
242
280
  throw new Error(`Error creating session: ${err.message}`);
@@ -273,6 +311,41 @@ function sleepMs(ms) {
273
311
  return new Promise((resolve) => setTimeout(resolve, ms));
274
312
  }
275
313
 
314
+ // Wait indefinitely for a deferred result. Resolves with `{ result }` once the
315
+ // answer is ready, or `{ interrupted: true }` if SIGINT is received while
316
+ // waiting (any detached child keeps running). The caller decides how to react
317
+ // to the interrupt (normally print a ticket and exit).
318
+ function waitForDeferredResultForever(filePath) {
319
+ return new Promise((resolve) => {
320
+ let timer = null;
321
+ let done = false;
322
+
323
+ const finish = (value) => {
324
+ if (done) return;
325
+ done = true;
326
+ if (timer) clearTimeout(timer);
327
+ process.removeListener('SIGINT', onSigint);
328
+ resolve(value);
329
+ };
330
+
331
+ function onSigint() {
332
+ finish({ interrupted: true });
333
+ }
334
+
335
+ const poll = () => {
336
+ const result = readDeferredResult(filePath);
337
+ if (result !== null) {
338
+ finish({ result });
339
+ return;
340
+ }
341
+ timer = setTimeout(poll, DEFER_POLL_MS);
342
+ };
343
+
344
+ process.on('SIGINT', onSigint);
345
+ poll();
346
+ });
347
+ }
348
+
276
349
  function appendUnusedFollowup(result, followupText) {
277
350
  if (!followupText) return result;
278
351
  return result + (result.endsWith('\n') ? '' : '\n') + followupText;
@@ -313,10 +386,58 @@ function injectQueuedFollowups(result, followups) {
313
386
  return before + (before.endsWith('\n') ? '' : '\n') + addition + after;
314
387
  }
315
388
 
316
- function formatDeferredResult(filePath, result, qaMode, unusedFollowupText) {
389
+ function formatDeferredResult(filePath, result, qaMode, unusedFollowupText, header) {
317
390
  const followups = qaMode ? readQueuedFollowups(filePath) : [];
318
391
  const output = qaMode ? injectQueuedFollowups(result, followups) : result;
319
- return appendUnusedFollowup(output, unusedFollowupText);
392
+ // The stored answer (produced by the detached child) may already carry the
393
+ // project/session header — don't prepend it a second time on retrieval.
394
+ const withHeader = qaMode && header && !carriesHeader(output) ? header + output : output;
395
+ return appendUnusedFollowup(withHeader, unusedFollowupText);
396
+ }
397
+
398
+ // Does the text already begin with the šŸ“‚ / šŸ’¬ header lines?
399
+ function carriesHeader(text) {
400
+ return /^(?:\u{1F4C2}|\u{1F4AC})/u.test(String(text || '').trimStart());
401
+ }
402
+
403
+ // Output a ready deferred result, clean up its temp files, and exit 0.
404
+ function outputReadyResult(filePath, result, qaMode, unusedFollowupText, header) {
405
+ try {
406
+ fs.unlinkSync(filePath);
407
+ } catch (err) {
408
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
409
+ process.exit(1);
410
+ }
411
+ try { fs.unlinkSync(filePath + '.lock'); } catch {}
412
+ process.stdout.write(formatDeferredResult(filePath, result, qaMode, unusedFollowupText, header));
413
+ removeQueuedFollowups(filePath);
414
+ process.exit(0);
415
+ }
416
+
417
+ // Structured header (project path + session id/title) prepended to --qa output,
418
+ // so a piped answer is self-describing about where it came from.
419
+ async function buildSessionHeader(server, sessionId) {
420
+ let title = sessionId;
421
+ let dir = '';
422
+ try {
423
+ const s = await getSession(server, sessionId);
424
+ if (s && (s.title || s.id)) title = s.title || s.id;
425
+ if (s && s.location && s.location.directory) dir = s.location.directory;
426
+ } catch {
427
+ // keep the plain id form
428
+ }
429
+ let out = '';
430
+ if (dir) out += `šŸ“‚ ${dir}\n`;
431
+ out += `šŸ’¬ ${title} [${sessionId}]\n`;
432
+ return out;
433
+ }
434
+
435
+ // Header for deferred-ticket retrieval (ticket carries server + sessionId).
436
+ async function ticketHeader(ticket, qaMode) {
437
+ if (qaMode && ticket && ticket.server && ticket.sessionId) {
438
+ try { return await buildSessionHeader(ticket.server, ticket.sessionId); } catch {}
439
+ }
440
+ return '';
320
441
  }
321
442
 
322
443
  async function readAllStdin() {
@@ -333,12 +454,68 @@ const HUMAN_ROW = 'šŸ‘¤: —————————————————\n'
333
454
  const AGENT_ROW = 'šŸ¤–: —————————————————\n'
334
455
  const FINAL_ROW = ' —————————————————\n';
335
456
 
457
+ // Write the prompt to a temp file, lock the output file, and spawn a detached
458
+ // `--defer-child` process that runs the prompt and writes the answer. Returns
459
+ // the ticket describing this deferred run.
460
+ function spawnDeferredChild({ promptText, serverBase, sessionName, newSession, qaMode, tgMode, flushMode, targetSessionId, deferSeconds }) {
461
+ const tempPath = generateTempFilename();
462
+ const promptTempPath = tempPath + '.prompt';
463
+
464
+ fs.writeFileSync(promptTempPath, promptText);
465
+ fs.writeFileSync(tempPath, '');
466
+ lockFile(tempPath);
467
+
468
+ const ticket = {
469
+ ctime: new Date().toISOString(),
470
+ path: tempPath,
471
+ defer: deferSeconds || 0,
472
+ server: serverBase,
473
+ sessionId: targetSessionId,
474
+ cancelled: false,
475
+ };
476
+
477
+ const childArgs = [
478
+ __filename,
479
+ '--defer-child',
480
+ '--prompt-file', promptTempPath,
481
+ '--output-file', tempPath,
482
+ ];
483
+ if (qaMode) childArgs.push('--qa');
484
+ if (tgMode === true) childArgs.push('--tg');
485
+ if (tgMode === false) childArgs.push('--no-tg');
486
+ if (flushMode) childArgs.push('--flush');
487
+ if (sessionName) {
488
+ childArgs.push('--session', sessionName);
489
+ if (newSession) childArgs.push('--new');
490
+ }
491
+ childArgs.push('--resolved-session-id', targetSessionId);
492
+ childArgs.push(serverBase);
493
+
494
+ const child = child_process.spawn(process.execPath, childArgs, {
495
+ detached: true,
496
+ stdio: 'ignore',
497
+ });
498
+ child.unref();
499
+
500
+ return ticket;
501
+ }
502
+
336
503
  async function main() {
337
504
  let serverBase = 'http://localhost:4096';
338
505
  let qaMode = false;
339
506
  let flushMode = false;
340
507
  let deferMode = false;
341
508
  let deferSeconds = 0;
509
+ // Project context from the nearest .ocmux.json (v2 model): project directory,
510
+ // stored session ID, and cached server URL. All null when no state file
511
+ // applies, in which case agentp keeps its legacy (unscoped) behavior.
512
+ const ctx = projectState.resolveContext();
513
+ const projectDir = ctx.directory;
514
+ const stateSession = ctx.session;
515
+ // Adopt the project's cached server (from .ocmux.json) unless an explicit
516
+ // server/port is given later in the argument loop (--getLast runs mid-loop,
517
+ // so adoption must happen before it).
518
+ if (ctx.server) serverBase = ctx.server;
342
519
  let deferChildMode = false;
343
520
  let onlineTicket = false;
344
521
  let promptFile = null;
@@ -373,6 +550,7 @@ async function main() {
373
550
  console.error(' --getLast Retrieve last N assistant answers (or QA pairs with --qa) from session history');
374
551
  console.error(' --session Target a specific session by name (creates it if not found with --new)');
375
552
  console.error(' --new Create a new session with the given title (requires --session)');
553
+ console.error(' --server OpenCode server URL (overrides .ocmux.json); e.g. http://host:4096');
376
554
  console.error(' --help Show this help message');
377
555
  console.error('');
378
556
  console.error('By default, --qa auto-detects tgagentp; standalone mode implies --no-tg.');
@@ -391,8 +569,9 @@ async function main() {
391
569
  console.error('');
392
570
  console.error('Arguments:');
393
571
  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.');
572
+ console.error(' Example: 4096, http://localhost:4096, http://192.168.1.50:4096');
573
+ console.error(' Omit it to resolve from the nearest .ocmux.json');
574
+ console.error(' (its directory selects the project, its session id the target).');
396
575
  console.error('');
397
576
  console.error('See also: tgagentp -- Telegram bridge (tgagentp --help), ocmux -- manage OpenCode tmux sessions (ocmux --help)');
398
577
  console.error('');
@@ -456,18 +635,12 @@ async function main() {
456
635
  }
457
636
  const n = parseInt(nStr, 10);
458
637
  try {
459
- const sessions = await listSessions(serverBase);
638
+ const sessions = await listSessions(serverBase, projectDir || undefined);
460
639
  if (!sessions || sessions.length === 0) {
461
640
  console.error('No sessions found.');
462
641
  process.exit(1);
463
642
  }
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
- });
643
+ const sorted = sortSessionsByRecency(sessions);
471
644
  const sessionId = sorted[0].id;
472
645
 
473
646
  function buildPairsFromMessages(messages) {
@@ -548,6 +721,13 @@ async function main() {
548
721
  tgMode = true;
549
722
  } else if (args[i] === '--no-tg') {
550
723
  tgMode = false;
724
+ } else if (args[i] === '--server') {
725
+ i++;
726
+ if (!args[i]) {
727
+ console.error("Error: '--server' requires a URL (e.g. http://host:4096)");
728
+ process.exit(1);
729
+ }
730
+ serverBase = args[i].replace(/\/+$/, '');
551
731
  } else if (args[i].includes('://')) {
552
732
  serverBase = args[i].replace(/\/+$/, '');
553
733
  } else if (/^\d+$/.test(args[i])) {
@@ -563,6 +743,8 @@ async function main() {
563
743
  }
564
744
  }
565
745
 
746
+ const preferredSession = (sessionName || newSession || resolvedSessionId) ? null : stateSession;
747
+
566
748
  // Resolve default: --qa → auto-detect, standalone → disabled
567
749
  if (tgMode === null) {
568
750
  tgMode = qaMode ? 'auto' : false;
@@ -596,6 +778,10 @@ async function main() {
596
778
  // ignored; the ticket's own "defer" timeout is used instead (default 0).
597
779
  const deferredPath = ticket.path;
598
780
 
781
+ if (ticket.cancelled) {
782
+ await cancelTicket(ticket);
783
+ }
784
+
599
785
  if (!fs.existsSync(deferredPath)) {
600
786
  process.stdout.write(`Error: deferred file not found: ${deferredPath}\n`);
601
787
  process.exit(1);
@@ -614,7 +800,7 @@ async function main() {
614
800
  process.exit(1);
615
801
  }
616
802
  try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
617
- process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ticket.followupText));
803
+ process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ticket.followupText, await ticketHeader(ticket, qaMode)));
618
804
  removeQueuedFollowups(deferredPath);
619
805
  process.exit(0);
620
806
  }
@@ -643,7 +829,7 @@ async function main() {
643
829
  process.exit(1);
644
830
  }
645
831
  try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
646
- process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ''));
832
+ process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, '', await ticketHeader(ticket, qaMode)));
647
833
  removeQueuedFollowups(deferredPath);
648
834
  process.exit(0);
649
835
  }
@@ -658,70 +844,41 @@ async function main() {
658
844
  }
659
845
 
660
846
  // This is a regular prompt — create deferred execution
661
- const tempPath = generateTempFilename();
662
- const promptTempPath = tempPath + '.prompt';
663
-
664
847
  let targetSessionId;
665
848
  try {
666
- targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession);
849
+ targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession, {
850
+ directory: projectDir,
851
+ preferredSession,
852
+ });
667
853
  } catch (err) {
668
854
  console.error(err.message);
669
855
  process.exit(1);
670
856
  }
671
857
 
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');
703
- }
704
- childArgs.push('--resolved-session-id', targetSessionId);
705
- childArgs.push(serverBase);
706
-
707
- const child = child_process.spawn(process.execPath, childArgs, {
708
- detached: true,
709
- stdio: 'ignore',
858
+ const newTicket = spawnDeferredChild({
859
+ promptText,
860
+ serverBase,
861
+ sessionName,
862
+ newSession,
863
+ qaMode,
864
+ tgMode,
865
+ flushMode,
866
+ targetSessionId,
867
+ deferSeconds,
710
868
  });
711
- child.unref();
712
869
 
713
870
  // If a timeout was given, block until the answer arrives or the timeout
714
871
  // expires. Return the response when it arrives; otherwise print a ticket.
715
872
  if (deferSeconds > 0) {
716
- const result = await waitForDeferredResult(tempPath, deferSeconds * 1000);
873
+ const result = await waitForDeferredResult(newTicket.path, deferSeconds * 1000);
717
874
  if (result !== null) {
718
875
  try {
719
- fs.unlinkSync(tempPath);
876
+ fs.unlinkSync(newTicket.path);
720
877
  } catch (err) {
721
878
  process.stdout.write(`Error reading deferred file: ${err.message}\n`);
722
879
  process.exit(1);
723
880
  }
724
- try { fs.unlinkSync(tempPath + '.lock'); } catch {}
881
+ try { fs.unlinkSync(newTicket.path + '.lock'); } catch {}
725
882
  process.stdout.write(result);
726
883
  process.exit(0);
727
884
  }
@@ -732,6 +889,93 @@ async function main() {
732
889
  process.exit(0);
733
890
  }
734
891
 
892
+ // Normal (non-deferred, non-child) mode: wait for the answer, but run it in a
893
+ // detached child so a Ctrl+C can hand the in-flight response off as a ticket.
894
+ if (!deferChildMode) {
895
+ const ticket = parseDeferredTicketInput(promptText);
896
+
897
+ if (ticket) {
898
+ // Recognize a deferred ticket: wait indefinitely for the answer, and
899
+ // re-print the ticket only if the user interrupts with Ctrl+C.
900
+ const deferredPath = ticket.path;
901
+
902
+ if (ticket.cancelled) {
903
+ await cancelTicket(ticket);
904
+ }
905
+
906
+ if (!fs.existsSync(deferredPath)) {
907
+ process.stdout.write(`Error: deferred file not found: ${deferredPath}\n`);
908
+ process.exit(1);
909
+ }
910
+
911
+ let unusedFollowup = ticket.followupText || '';
912
+
913
+ // If the answer is already ready, return it immediately.
914
+ let result = ticket.followupText ? readDeferredResult(deferredPath) : null;
915
+ if (result !== null) {
916
+ outputReadyResult(deferredPath, result, qaMode, unusedFollowup, await ticketHeader(ticket, qaMode));
917
+ }
918
+
919
+ // Queue any follow-up text to the original session (same as --defer).
920
+ if (ticket.followupText) {
921
+ if (!ticket.server || !ticket.sessionId) {
922
+ 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');
923
+ process.exit(1);
924
+ }
925
+ try {
926
+ await sendToSessionAsync(ticket.server, ticket.sessionId, ticket.followupText);
927
+ appendQueuedFollowup(deferredPath, ticket.followupText);
928
+ unusedFollowup = ''; // follow-up already queued; don't echo it again
929
+ } catch (err) {
930
+ process.stdout.write(`Error queueing follow-up prompt: ${err.message}\n`);
931
+ process.exit(1);
932
+ }
933
+ }
934
+
935
+ const wait = await waitForDeferredResultForever(deferredPath);
936
+ if (wait.interrupted) printTicketAndExit(ticket, onlineTicket);
937
+ outputReadyResult(deferredPath, wait.result, qaMode, unusedFollowup, await ticketHeader(ticket, qaMode));
938
+ }
939
+
940
+ // 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);
950
+ }
951
+
952
+ const newTicket = spawnDeferredChild({
953
+ promptText,
954
+ serverBase,
955
+ sessionName,
956
+ newSession,
957
+ qaMode,
958
+ tgMode,
959
+ flushMode,
960
+ targetSessionId,
961
+ deferSeconds: 0,
962
+ });
963
+
964
+ const wait = await waitForDeferredResultForever(newTicket.path);
965
+ if (wait.interrupted) printTicketAndExit(newTicket, onlineTicket);
966
+
967
+ // Answer ready — output it and clean up.
968
+ try {
969
+ fs.unlinkSync(newTicket.path);
970
+ } catch (err) {
971
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
972
+ process.exit(1);
973
+ }
974
+ try { fs.unlinkSync(newTicket.path + '.lock'); } catch {}
975
+ process.stdout.write(wait.result);
976
+ process.exit(0);
977
+ }
978
+
735
979
  // Handle --defer-child mode (child process)
736
980
  let actualPromptText = promptText;
737
981
  if (deferChildMode && promptFile) {
@@ -749,11 +993,27 @@ async function main() {
749
993
  // Wrap main processing in try-catch for deferChildMode to write errors to output file
750
994
  let tgError = null;
751
995
  async function runMain() {
752
- const sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession);
996
+ const sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession, {
997
+ directory: projectDir,
998
+ preferredSession,
999
+ });
1000
+
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;
1008
+ }
1009
+ if (projectDir) ocmuxLib.focusWindowByDir(projectDir);
1010
+ } catch {
1011
+ // tmux focus is best-effort; never fail the prompt because of it
1012
+ }
753
1013
 
754
1014
  let answer;
755
1015
  try {
756
- answer = await sendToSession(serverBase, sessionId, actualPromptText);
1016
+ answer = await sendToSession(serverBase, sessionId, prompt);
757
1017
  } catch (err) {
758
1018
  throw new Error(`Error sending prompt: ${err.message}`);
759
1019
  }
@@ -761,7 +1021,9 @@ async function main() {
761
1021
  // Build the message to send to Telegram (QA pair with rulers)
762
1022
  let tgMessage = answer;
763
1023
  if (qaMode) {
764
- tgMessage = HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
1024
+ let header = '';
1025
+ try { header = await buildSessionHeader(serverBase, sessionId); } catch {}
1026
+ tgMessage = header + HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
765
1027
  if (!answer.endsWith('\n')) tgMessage += '\n';
766
1028
  tgMessage += FINAL_ROW;
767
1029
  }
@@ -843,4 +1105,4 @@ if (require.main === module) {
843
1105
  });
844
1106
  }
845
1107
 
846
- module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult };
1108
+ module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult, waitForDeferredResultForever };