@link-assistant/hive-mind 2.1.2 → 2.1.4

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.
@@ -2,13 +2,13 @@
2
2
  /**
3
3
  * Bidirectional Interactive Mode Library
4
4
  *
5
- * [EXPERIMENTAL] This module provides bidirectional real-time communication during Claude execution.
6
- * It monitors PR comments for user feedback and queues it for injection into the running Claude session.
5
+ * [EXPERIMENTAL] This module provides bidirectional real-time communication during tool execution.
6
+ * It monitors issue/PR comments for user feedback and queues them for injection into the running tool session.
7
7
  *
8
8
  * Key features:
9
- * - Monitors GitHub PR comments for new user feedback
10
- * - Queues feedback messages for injection into Claude's stdin
11
- * - Works with Claude CLI's --input-format stream-json mode
9
+ * - Monitors GitHub issue/PR comments for new user feedback
10
+ * - Queues feedback messages for injection into the tool stdin
11
+ * - Works with Claude/Agent CLI --input-format stream-json mode
12
12
  * - Filters out system-generated comments (from interactive mode itself)
13
13
  *
14
14
  * Usage:
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
26
+ import { getLiveInputCapability, getLiveInputCapabilityRows, getLiveInputMode, isLiveInputSupported, LIVE_INPUT_MODE_FALLBACK, LIVE_INPUT_MODE_STREAM } from './live-input-capabilities.lib.mjs';
26
27
  // Configuration constants
27
28
  const CONFIG = {
28
29
  // Minimum time between comment checks to avoid rate limiting (in ms)
@@ -31,9 +32,9 @@ const CONFIG = {
31
32
  DEFAULT_POLL_INTERVAL: 15000,
32
33
  // Maximum queued feedback messages
33
34
  MAX_QUEUE_SIZE: 50,
34
- // Default keep-alive for the headless Claude process between stream-json
35
- // turns. Claude Code exits after this many ms with no new input once it
36
- // has replied, so new PR comments have a window to flow in as additional
35
+ // Default keep-alive for a headless stream-json process between turns.
36
+ // Claude Code exits after this many ms with no new input once it
37
+ // has replied, so new issue/PR comments have a window to flow in as additional
37
38
  // user messages. Issue #817.
38
39
  DEFAULT_EXIT_AFTER_STOP_DELAY_MS: 60_000,
39
40
  // Signature to identify system-generated comments
@@ -54,12 +55,13 @@ const isSystemComment = body => {
54
55
  };
55
56
 
56
57
  /**
57
- * Format a user feedback message for Claude CLI's stream-json input
58
+ * Format a user feedback message for Claude-compatible stream-json input.
59
+ * Agent accepts the same `type: user` frame shape in stream-json mode.
58
60
  *
59
61
  * @param {string} feedbackText - The user's feedback text
60
62
  * @param {Object} [options]
61
63
  * @param {string} [options.kind='comment'] - Source kind: 'comment', 'ci', 'uncommitted', 'metadata'
62
- * @returns {string} JSON string ready to write to Claude's stdin
64
+ * @returns {string} JSON string ready to write to a stream-json stdin
63
65
  */
64
66
  const formatFeedbackForClaude = (feedbackText, options = {}) => {
65
67
  const kind = options.kind || 'comment';
@@ -98,10 +100,10 @@ const formatFeedbackForClaude = (feedbackText, options = {}) => {
98
100
  };
99
101
 
100
102
  /**
101
- * Build the first stream-json user frame for a Claude Code headless session.
103
+ * Build the first stream-json user frame for a headless tool session.
102
104
  *
103
105
  * Issue #817: When --accept-incomming-comments-as-input is enabled, solve
104
- * spawns Claude with `--input-format stream-json` and a pipe stdin. The
106
+ * spawns the tool with `--input-format stream-json` and a pipe stdin. The
105
107
  * initial user prompt must therefore be delivered as a NDJSON frame rather
106
108
  * than via `-p`. This matches the pattern from the reference gist
107
109
  * `claude-stream-persistent.mjs`.
@@ -125,7 +127,7 @@ const buildInitialUserFrame = (promptText, options = {}) => {
125
127
  };
126
128
 
127
129
  /**
128
- * Write one NDJSON frame into a live Claude stdin stream.
130
+ * Write one NDJSON frame into a live stream-json stdin stream.
129
131
  *
130
132
  * Returns true on a successful write, false when the stream is missing or
131
133
  * closed. Never throws — callers just log and continue. Internal helper used
@@ -147,7 +149,7 @@ const writeFrameToStdin = async (stream, jsonFrame, logFn, verbose = false) => {
147
149
  } catch (err) {
148
150
  if (logFn && verbose) {
149
151
  try {
150
- await logFn(`⚠️ Bidirectional mode: Failed to write to Claude stdin: ${err.message}`, { verbose: true });
152
+ await logFn(`⚠️ Bidirectional mode: Failed to write to tool stdin: ${err.message}`, { verbose: true });
151
153
  } catch {
152
154
  /* ignore logger errors */
153
155
  }
@@ -173,10 +175,11 @@ const writeFrameToStdin = async (stream, jsonFrame, logFn, verbose = false) => {
173
175
  * @param {string} [options.deliveryMode='stream'] - 'stream' (immediate forward) or 'queue' (hold until AI idle). Issue #1708.
174
176
  * @param {boolean} [options.streamStatusToInput=false] - Also stream CI/uncommitted/PR-status changes as NDJSON frames. Issue #1708.
175
177
  * @param {number} [options.statusPollInterval=60000] - Status-poller interval (ms) when streamStatusToInput is on.
178
+ * @param {string} [options.toolLabel='AI tool'] - Human label for logging stdin writes.
176
179
  * @returns {Object} Handler object with monitoring methods
177
180
  */
178
181
  export const createBidirectionalHandler = options => {
179
- const { owner, repo, prNumber, issueNumber, tempDir, $, log, verbose = false, pollInterval = CONFIG.DEFAULT_POLL_INTERVAL, excludeOwnComments = false, deliveryMode = 'stream', streamStatusToInput = false, statusPollInterval = 60000 } = options;
182
+ const { owner, repo, prNumber, issueNumber, tempDir, $, log, verbose = false, pollInterval = CONFIG.DEFAULT_POLL_INTERVAL, excludeOwnComments = false, deliveryMode = 'stream', streamStatusToInput = false, statusPollInterval = 60000, toolLabel = 'AI tool' } = options;
180
183
  // Resolved lazily on first check, cached for the lifetime of the handler
181
184
  let ownUserLogin = null;
182
185
  let ownUserResolved = false;
@@ -205,7 +208,7 @@ export const createBidirectionalHandler = options => {
205
208
  processedCommentIds: new Set(),
206
209
  totalCommentsProcessed: 0,
207
210
  totalFeedbackQueued: 0,
208
- // Issue #817: Writable stdin of the live Claude process. When set, new
211
+ // Issue #817/#2007: Writable stdin of the live stream-json process. When set, new
209
212
  // non-system comments are written directly as NDJSON frames rather than
210
213
  // only accumulated in feedbackQueue.
211
214
  claudeStdin: null,
@@ -230,7 +233,36 @@ export const createBidirectionalHandler = options => {
230
233
  };
231
234
 
232
235
  /**
233
- * Fetch recent comments from the PR
236
+ * Fetch comments from a GitHub API endpoint.
237
+ *
238
+ * @param {string} apiPath
239
+ * @param {string} source
240
+ * @returns {Promise<Array>} Array of normalized comment objects
241
+ * @private
242
+ */
243
+ const fetchCommentsFromEndpoint = async (apiPath, source) => {
244
+ try {
245
+ const result = await $`gh api ${apiPath} --paginate --slurp`;
246
+ const parsed = JSON.parse(result.stdout?.toString() || '[]');
247
+ const comments = Array.isArray(parsed) && parsed.every(Array.isArray) ? parsed.flat() : parsed;
248
+ return comments.map(comment => ({
249
+ id: comment.id,
250
+ body: comment.body || '',
251
+ created_at: comment.created_at,
252
+ user: typeof comment.user === 'string' ? comment.user : comment.user?.login || '',
253
+ source,
254
+ }));
255
+ } catch (error) {
256
+ if (verbose) {
257
+ await log(`⚠️ Bidirectional mode: Failed to fetch ${source} comments: ${error.message}`, { verbose: true });
258
+ }
259
+ return [];
260
+ }
261
+ };
262
+
263
+ /**
264
+ * Fetch recent comments from PR conversation, PR review, and source issue.
265
+ *
234
266
  * @returns {Promise<Array>} Array of comment objects
235
267
  * @private
236
268
  */
@@ -242,24 +274,29 @@ export const createBidirectionalHandler = options => {
242
274
  return [];
243
275
  }
244
276
 
245
- try {
246
- // Fetch comments using gh api with pagination (GitHub defaults to 30/page), sorted by created_at desc
247
- const result = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate --jq '[.[] | {id: .id, body: .body, created_at: .created_at, user: .user.login}] | sort_by(.created_at) | reverse'`;
248
- const comments = JSON.parse(result.stdout.toString());
249
- return comments;
250
- } catch (error) {
251
- if (verbose) {
252
- await log(`⚠️ Bidirectional mode: Failed to fetch comments: ${error.message}`, { verbose: true });
253
- }
254
- return [];
277
+ const comments = [...(await fetchCommentsFromEndpoint(`repos/${owner}/${repo}/issues/${prNumber}/comments`, 'pull request conversation')), ...(await fetchCommentsFromEndpoint(`repos/${owner}/${repo}/pulls/${prNumber}/comments`, 'pull request review'))];
278
+
279
+ if (issueNumber && String(issueNumber) !== String(prNumber)) {
280
+ comments.push(...(await fetchCommentsFromEndpoint(`repos/${owner}/${repo}/issues/${issueNumber}/comments`, 'issue')));
255
281
  }
282
+
283
+ const seenKeys = new Set();
284
+ const uniqueComments = [];
285
+ for (const comment of comments) {
286
+ const key = comment.id == null ? `${comment.source}:${comment.created_at}:${comment.user}:${comment.body}` : String(comment.id);
287
+ if (seenKeys.has(key)) continue;
288
+ seenKeys.add(key);
289
+ uniqueComments.push(comment);
290
+ }
291
+
292
+ return uniqueComments.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
256
293
  };
257
294
 
258
295
  /**
259
296
  * Issue #1708: Delivery-mode-aware frame dispatcher.
260
297
  *
261
298
  * - In stream mode (default for --accept-incomming-comments-as-input),
262
- * the frame is written to Claude stdin immediately.
299
+ * the frame is written to the tool stdin immediately.
263
300
  * - In queue mode (default for --auto-input-until-mergeable), the frame
264
301
  * is buffered in state.pendingFrames while the AI is busy; on idle
265
302
  * (markAiIdle) the buffer is flushed to stdin in FIFO order.
@@ -295,7 +332,7 @@ export const createBidirectionalHandler = options => {
295
332
  if (ok) {
296
333
  state.totalFeedbackStreamed++;
297
334
  if (verbose) {
298
- await log(`📤 Bidirectional mode: Streamed frame (${meta.kind || 'frame'}: ${meta.label || ''}) into Claude stdin`, { verbose: true });
335
+ await log(`📤 Bidirectional mode: Streamed frame (${meta.kind || 'frame'}: ${meta.label || ''}) into ${toolLabel} stdin`, { verbose: true });
299
336
  }
300
337
  }
301
338
  return ok;
@@ -339,7 +376,7 @@ export const createBidirectionalHandler = options => {
339
376
  state.totalFramesFlushed++;
340
377
  state.totalFeedbackStreamed++;
341
378
  if (verbose) {
342
- await log(`📤 Bidirectional mode: Flushed pending frame (${meta?.kind || 'frame'}: ${meta?.label || ''}) into Claude stdin`, { verbose: true });
379
+ await log(`📤 Bidirectional mode: Flushed pending frame (${meta?.kind || 'frame'}: ${meta?.label || ''}) into ${toolLabel} stdin`, { verbose: true });
343
380
  }
344
381
  }
345
382
  return flushed;
@@ -399,14 +436,14 @@ export const createBidirectionalHandler = options => {
399
436
  state.totalFeedbackQueued++;
400
437
 
401
438
  if (verbose) {
402
- await log(`📥 Bidirectional mode: Queued feedback from @${comment.user} (comment #${comment.id})`, { verbose: true });
439
+ await log(`📥 Bidirectional mode: Queued feedback from @${comment.user} (${comment.source || 'comment'} #${comment.id})`, { verbose: true });
403
440
  }
404
441
 
405
442
  // Issue #817 / #1708: Dispatch through the delivery-mode router so
406
443
  // queue-comments-to-input can hold the frame until the AI is idle.
407
444
  await dispatchFrame(formattedMessage, {
408
445
  kind: 'comment',
409
- label: `comment #${comment.id} from @${comment.user}`,
446
+ label: `${comment.source || 'comment'} #${comment.id} from @${comment.user}`,
410
447
  });
411
448
  } else {
412
449
  if (verbose) {
@@ -484,7 +521,7 @@ export const createBidirectionalHandler = options => {
484
521
  * the same failing check doesn't re-emit on every poll.
485
522
  *
486
523
  * Failures in any sub-check are swallowed and logged — the poller must
487
- * never break the live Claude session.
524
+ * never break the live tool session.
488
525
  *
489
526
  * @private
490
527
  */
@@ -573,7 +610,7 @@ export const createBidirectionalHandler = options => {
573
610
  };
574
611
 
575
612
  /**
576
- * Start monitoring PR comments for user feedback
613
+ * Start monitoring issue/PR comments for user feedback
577
614
  *
578
615
  * @returns {Promise<void>}
579
616
  */
@@ -604,7 +641,7 @@ export const createBidirectionalHandler = options => {
604
641
  }, interval);
605
642
 
606
643
  if (verbose) {
607
- await log(`🔌 Bidirectional mode: Started monitoring PR #${prNumber} (polling every ${interval / 1000}s)`, { verbose: true });
644
+ await log(`🔌 Bidirectional mode: Started monitoring issue/PR comments for PR #${prNumber} (polling every ${interval / 1000}s)`, { verbose: true });
608
645
  }
609
646
 
610
647
  // Issue #1708: When --auto-input-until-mergeable enables status streaming,
@@ -632,7 +669,7 @@ export const createBidirectionalHandler = options => {
632
669
  };
633
670
 
634
671
  /**
635
- * Stop monitoring PR comments
672
+ * Stop monitoring issue/PR comments
636
673
  *
637
674
  * @returns {Promise<void>}
638
675
  */
@@ -756,28 +793,32 @@ export const createBidirectionalHandler = options => {
756
793
  };
757
794
 
758
795
  /**
759
- * Attach a live Claude stdin stream to the handler.
796
+ * Attach a live tool stdin stream to the handler.
760
797
  *
761
- * Issue #817: Once attached, every new non-system comment detected by the
762
- * polling loop is also written to this stream as a NDJSON `user` frame.
798
+ * Issue #817/#2007: Once attached, every new non-system comment detected by
799
+ * the polling loop is also written to this stream as a NDJSON `user` frame.
763
800
  * Safe to call before or after monitoring starts.
764
801
  *
765
802
  * @param {Object} stream - Writable stream (child.stdin)
766
803
  */
767
- const attachClaudeStdin = stream => {
804
+ const attachToolStdin = stream => {
768
805
  state.claudeStdin = stream || null;
769
806
  };
770
807
 
771
808
  /**
772
- * Detach the Claude stdin stream. After this call, comments are only queued.
809
+ * Detach the tool stdin stream. After this call, comments are only queued.
773
810
  */
774
- const detachClaudeStdin = () => {
811
+ const detachToolStdin = () => {
775
812
  state.claudeStdin = null;
776
813
  };
777
814
 
815
+ // Compatibility aliases for the original Claude-only public surface.
816
+ const attachClaudeStdin = attachToolStdin;
817
+ const detachClaudeStdin = detachToolStdin;
818
+
778
819
  /**
779
820
  * Stream the initial user prompt as a stream-json frame into the attached
780
- * Claude stdin. Use this when running Claude with `--input-format stream-json`.
821
+ * tool stdin. Use this when running a tool with `--input-format stream-json`.
781
822
  *
782
823
  * @param {string} promptText
783
824
  * @param {Object} [options]
@@ -791,7 +832,7 @@ export const createBidirectionalHandler = options => {
791
832
  };
792
833
 
793
834
  /**
794
- * Stream a non-comment feedback message into the attached Claude stdin.
835
+ * Stream a non-comment feedback message into the attached tool stdin.
795
836
  *
796
837
  * @param {string} feedbackText
797
838
  * @param {Object} [options]
@@ -839,6 +880,8 @@ export const createBidirectionalHandler = options => {
839
880
  markCommentAsProcessed,
840
881
  initializeWithExistingComments,
841
882
  initializeFromCurrentComments,
883
+ attachToolStdin,
884
+ detachToolStdin,
842
885
  attachClaudeStdin,
843
886
  detachClaudeStdin,
844
887
  streamInitialPrompt,
@@ -870,8 +913,7 @@ export const createBidirectionalHandler = options => {
870
913
  * @returns {boolean} Whether bidirectional interactive mode is supported
871
914
  */
872
915
  export const isBidirectionalModeSupported = tool => {
873
- // Currently only supported for Claude due to --input-format stream-json support
874
- return tool === 'claude';
916
+ return isLiveInputSupported(tool);
875
917
  };
876
918
 
877
919
  /**
@@ -891,18 +933,45 @@ export const isBidirectionalModeSupported = tool => {
891
933
  * @returns {Promise<boolean>} Whether configuration is valid for the chosen tool
892
934
  */
893
935
  export const validateBidirectionalModeConfig = async (argv, log) => {
894
- // Issue #1708 Stage 1: --auto-input-until-mergeable enables only the
895
- // input-side of bidirectional mode accepting incoming PR/issue comments
896
- // as new input without enabling --interactive-mode (which would push
897
- // tool output back as PR comments). The full streaming-aware
898
- // watchUntilMergeable replacement is staged in subsequent PRs until
899
- // those land, this composition gives users on --tool claude the
900
- // mid-session NDJSON input pipe that already exists for issue #817 and
901
- // is a graceful no-op for non-Claude tools (the validator below disables
902
- // it). The --auto-restart-until-mergeable / --auto-resume loops remain
903
- // active as fallbacks; the goal is for them to stay dormant when input
904
- // streaming keeps the session alive.
936
+ // Issue #1708/#2007: --auto-input-until-mergeable enables only the
937
+ // input-side of bidirectional mode without enabling --interactive-mode
938
+ // (which would push tool output back as PR comments).
939
+ //
940
+ // Live event input is available for every tool, but the delivery mode differs:
941
+ // - stream-mode tools (Claude, Agent) get a live stdin pipe.
942
+ // - fallback-mode tools (Codex, opencode, gemini, qwen, ...) use the
943
+ // universal restart/resume fallback: the run finishes the current session
944
+ // in the JSON output, stops, and resumes the AI with the new issue/PR
945
+ // events as feedback via --auto-restart-until-mergeable.
905
946
  if (argv.autoInputUntilMergeable) {
947
+ if (getLiveInputMode(argv.tool) === LIVE_INPUT_MODE_FALLBACK) {
948
+ // No live stdin channel for this tool: activate the restart/resume
949
+ // fallback instead of disabling the feature. Live comment streaming
950
+ // stays off; the auto-restart loop delivers the same events at session
951
+ // boundaries.
952
+ const capability = getLiveInputCapability(argv.tool);
953
+ argv.acceptIncommingCommentsAsInput = false;
954
+ argv.excludeAllOwnIncommingCommentsFromInput = false;
955
+ argv.streamCommentsToInput = false;
956
+ argv.queueCommentsToInput = false;
957
+ // Ensure the fallback loop is actually running. It defaults to enabled,
958
+ // but --auto-input-until-mergeable relies on it entirely for these tools,
959
+ // so re-enable it unless the user explicitly opted out.
960
+ if (argv.autoRestartUntilMergeable !== false) {
961
+ argv.autoRestartUntilMergeable = true;
962
+ }
963
+ await log(`🔁 --auto-input-until-mergeable: live streaming input is not available for --tool ${argv.tool}; using the restart/resume fallback.`, { level: 'info' });
964
+ await log(` ${capability.unsupportedReason}`, { level: 'info' });
965
+ if (capability.futureProtocol) {
966
+ await log(` Candidate live-streaming protocol: ${capability.futureProtocol} (tracked in link-assistant/agent).`, { level: 'info' });
967
+ }
968
+ if (argv.autoRestartUntilMergeable === false) {
969
+ await log(' ⚠️ --no-auto-restart-until-mergeable disables the fallback, so no live input mechanism remains for this tool.', { level: 'warning' });
970
+ } else {
971
+ await log(' The auto-restart-until-mergeable loop will resume the session with new issue/PR events (comments, title/body changes, CI failures, conflicts).', { level: 'info' });
972
+ }
973
+ return true;
974
+ }
906
975
  if (!argv.acceptIncommingCommentsAsInput) argv.acceptIncommingCommentsAsInput = true;
907
976
  // Default delivery mode for --auto-input-until-mergeable is queue:
908
977
  // hold comments until the AI is idle so the model can finish the
@@ -935,10 +1004,24 @@ export const validateBidirectionalModeConfig = async (argv, log) => {
935
1004
  // Nothing more to validate if no incoming-comment acceptance is requested
936
1005
  if (!argv.acceptIncommingCommentsAsInput) return true;
937
1006
 
938
- // Tool support: currently only Claude (uses --input-format stream-json)
1007
+ // Live comment *streaming* is only wired for stream-mode tools (uses
1008
+ // --input-format stream-json). The universal restart/resume fallback is reached via
1009
+ // --auto-input-until-mergeable (handled above), not through the standalone
1010
+ // --accept-incomming-comments-as-input / --bidirectional-interactive-mode
1011
+ // flags, which are specifically about live streaming.
939
1012
  if (!isBidirectionalModeSupported(argv.tool)) {
940
- await log(`⚠️ --accept-incomming-comments-as-input is only supported for --tool claude (current: ${argv.tool})`, { level: 'warning' });
941
- await log(' Incoming-comment acceptance will be disabled for this session.', { level: 'warning' });
1013
+ const capability = getLiveInputCapability(argv.tool);
1014
+ const supportedTools = getLiveInputCapabilityRows()
1015
+ .filter(row => row.mode === LIVE_INPUT_MODE_STREAM)
1016
+ .map(row => `--tool ${row.tool}`)
1017
+ .join(' or ');
1018
+ await log(`⚠️ Live comment streaming is not supported for --tool ${argv.tool} in this build (supported: ${supportedTools}).`, { level: 'warning' });
1019
+ await log(` ${capability.unsupportedReason}`, { level: 'warning' });
1020
+ if (capability.futureProtocol) {
1021
+ await log(` Candidate follow-up protocol: ${capability.futureProtocol}.`, { level: 'warning' });
1022
+ }
1023
+ await log(' Live incoming-comment streaming will be disabled for this session.', { level: 'warning' });
1024
+ await log(' Tip: use --auto-input-until-mergeable to deliver the same issue/PR events through the restart/resume fallback instead.', { level: 'warning' });
942
1025
  argv.acceptIncommingCommentsAsInput = false;
943
1026
  argv.excludeAllOwnIncommingCommentsFromInput = false;
944
1027
  argv.streamCommentsToInput = false;
@@ -947,10 +1030,11 @@ export const validateBidirectionalModeConfig = async (argv, log) => {
947
1030
  }
948
1031
 
949
1032
  const deliveryMode = argv.queueCommentsToInput ? 'queue' : 'stream';
1033
+ const capability = getLiveInputCapability(argv.tool);
950
1034
  await log('🔌 Bidirectional Interactive Mode: ENABLED (experimental)', { level: 'info' });
951
1035
  await log(` accept-incomming-comments-as-input: true${argv.excludeAllOwnIncommingCommentsFromInput ? ', exclude-all-own-incomming-comments-from-input: true' : ''}`, { level: 'info' });
952
1036
  await log(` delivery mode: ${deliveryMode}-comments-to-input`, { level: 'info' });
953
- await log(' PR comments will be monitored and queued as feedback for Claude.', { level: 'info' });
1037
+ await log(` Issue/PR comments will be monitored and queued as feedback for ${capability.label}.`, { level: 'info' });
954
1038
 
955
1039
  return true;
956
1040
  };
@@ -979,6 +1063,8 @@ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, i
979
1063
  await log('⚠️ Bidirectional mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
980
1064
  return null;
981
1065
  }
1066
+ const capability = getLiveInputCapability(argv.tool);
1067
+ const toolLabel = capability.label || argv.tool || 'AI tool';
982
1068
  // Issue #1708: Resolve delivery mode from argv. validateBidirectionalModeConfig
983
1069
  // already enforces queue-wins-over-stream and the per-flag defaults; here we
984
1070
  // just translate the booleans into the handler-side enum.
@@ -988,7 +1074,7 @@ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, i
988
1074
  // --accept-incomming-comments-as-input path keeps the existing #817 behavior
989
1075
  // of forwarding only comments.
990
1076
  const streamStatusToInput = !!argv.autoInputUntilMergeable;
991
- await log('🔌 Bidirectional mode: Creating handler to accept incoming PR comments as Claude input', { verbose: true });
1077
+ await log(`🔌 Bidirectional mode: Creating handler to accept incoming issue/PR comments as ${toolLabel} input`, { verbose: true });
992
1078
  const handler = createBidirectionalHandler({
993
1079
  owner,
994
1080
  repo,
@@ -1002,6 +1088,7 @@ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, i
1002
1088
  excludeOwnComments: !!argv.excludeAllOwnIncommingCommentsFromInput,
1003
1089
  deliveryMode,
1004
1090
  streamStatusToInput,
1091
+ toolLabel,
1005
1092
  });
1006
1093
  await handler.initializeFromCurrentComments();
1007
1094
  await handler.startMonitoring();
@@ -1010,8 +1097,8 @@ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, i
1010
1097
  };
1011
1098
 
1012
1099
  /**
1013
- * Attach a live Claude process to the handler so new comments stream into
1014
- * its stdin as NDJSON frames. Also writes the initial user prompt as the
1100
+ * Attach a live tool process to the handler so new comments stream into its
1101
+ * stdin as NDJSON frames. Also writes the initial user prompt as the
1015
1102
  * first frame so the run starts normally. Issue #817.
1016
1103
  *
1017
1104
  * Safe to call with a null handler (no-op). Logs diagnostics but never throws.
@@ -1021,19 +1108,26 @@ export const setupBidirectionalHandler = async ({ argv, owner, repo, prNumber, i
1021
1108
  * @param {string} prompt - Initial user prompt text
1022
1109
  * @param {Function} log
1023
1110
  * @param {boolean} [verbose=false]
1111
+ * @param {Object} [options]
1112
+ * @param {string} [options.toolLabel='AI tool']
1024
1113
  * @returns {Promise<boolean>} Whether streaming input is active
1025
1114
  */
1026
- export const attachStreamingInput = async (handler, execCommand, prompt, log, verbose = false) => {
1115
+ export const attachStreamingInput = async (handler, execCommand, prompt, log, verbose = false, options = {}) => {
1027
1116
  if (!handler || !execCommand) return false;
1117
+ const toolLabel = options.toolLabel || 'AI tool';
1028
1118
  try {
1029
1119
  const stdinStream = await execCommand.streams.stdin;
1030
1120
  if (!stdinStream) {
1031
- if (verbose) await log('⚠️ Bidirectional mode: Could not acquire Claude stdin stream; falling back to queued-only feedback.', { verbose: true });
1121
+ if (verbose) await log(`⚠️ Bidirectional mode: Could not acquire ${toolLabel} stdin stream; falling back to queued-only feedback.`, { verbose: true });
1032
1122
  return false;
1033
1123
  }
1034
- handler.attachClaudeStdin(stdinStream);
1124
+ if (typeof handler.attachToolStdin === 'function') {
1125
+ handler.attachToolStdin(stdinStream);
1126
+ } else {
1127
+ handler.attachClaudeStdin(stdinStream);
1128
+ }
1035
1129
  const ok = await handler.streamInitialPrompt(prompt);
1036
- if (verbose) await log(`🔌 Bidirectional mode: Streaming input ${ok ? 'ENABLED' : 'FAILED'} (wrote initial user frame to Claude stdin).`, { verbose: true });
1130
+ if (verbose) await log(`🔌 Bidirectional mode: Streaming input ${ok ? 'ENABLED' : 'FAILED'} (wrote initial user frame to ${toolLabel} stdin).`, { verbose: true });
1037
1131
  return ok;
1038
1132
  } catch (attachError) {
1039
1133
  await log(`⚠️ Bidirectional mode: Failed to attach stdin (${attachError.message}); continuing without live streaming.`, { verbose: true });
@@ -1052,7 +1146,11 @@ export const attachStreamingInput = async (handler, execCommand, prompt, log, ve
1052
1146
  export const finalizeBidirectionalHandler = async (handler, log) => {
1053
1147
  if (!handler) return [];
1054
1148
  try {
1055
- handler.detachClaudeStdin?.();
1149
+ if (typeof handler.detachToolStdin === 'function') {
1150
+ handler.detachToolStdin();
1151
+ } else {
1152
+ handler.detachClaudeStdin?.();
1153
+ }
1056
1154
  await handler.stopMonitoring();
1057
1155
  const state = handler.getState();
1058
1156
  const queuedFeedback = handler.getAllQueuedFeedback();
@@ -1062,14 +1160,14 @@ export const finalizeBidirectionalHandler = async (handler, log) => {
1062
1160
  await log(` • From @${feedback.user}: ${feedback.body.substring(0, 100)}${feedback.body.length > 100 ? '...' : ''}`, { level: 'info' });
1063
1161
  }
1064
1162
  if (state.totalFeedbackStreamed > 0) {
1065
- await log(` 📤 ${state.totalFeedbackStreamed} of these were streamed live into Claude stdin.`, { level: 'info' });
1163
+ await log(` 📤 ${state.totalFeedbackStreamed} of these were streamed live into the tool stdin.`, { level: 'info' });
1066
1164
  } else {
1067
1165
  await log(' 💡 This feedback will be available for the next continuation of this task.', { level: 'info' });
1068
1166
  }
1069
1167
  } else {
1070
1168
  await log('📊 Bidirectional mode: No new feedback received during execution', { verbose: true });
1071
1169
  }
1072
- await log(`📊 Bidirectional mode stats: ${state.totalCommentsProcessed} comments processed, ${state.totalFeedbackQueued} feedback queued, ${state.totalFeedbackStreamed} streamed into Claude stdin`, { verbose: true });
1170
+ await log(`📊 Bidirectional mode stats: ${state.totalCommentsProcessed} comments processed, ${state.totalFeedbackQueued} feedback queued, ${state.totalFeedbackStreamed} streamed into tool stdin`, { verbose: true });
1073
1171
  return queuedFeedback;
1074
1172
  } catch (bidirectionalError) {
1075
1173
  await log(`⚠️ Bidirectional mode cleanup error: ${bidirectionalError.message}`, { verbose: true });
@@ -9,12 +9,15 @@ const ENGLISH_LIMITS = {
9
9
  balance: 'balance',
10
10
  claude_5_hour_session: 'Claude 5 hour session',
11
11
  claude_limits: 'Claude limits',
12
+ claude_subscription_title: 'Claude{{plan}} subscription',
13
+ chatgpt_subscription_title: 'ChatGPT{{plan}} subscription',
12
14
  codex_5_hour_session: 'Codex 5 hour session',
13
15
  codex_credits: 'Codex credits',
14
16
  codex_limits: 'Codex limits',
15
17
  cpu: 'CPU',
16
18
  cpu_cores_used: 'CPU cores used',
17
19
  current_time: 'Current time',
20
+ current_week: 'Current week',
18
21
  current_week_all_models: 'Current week (all models)',
19
22
  current_week_sonnet_only: 'Current week (Sonnet only)',
20
23
  disabled_by_admin: '`--show-limits` is disabled by the bot administrator.',
@@ -25,6 +28,7 @@ const ENGLISH_LIMITS = {
25
28
  duration_second_short: 's',
26
29
  end: 'End',
27
30
  five_hour_session: '5h session',
31
+ five_hour_limit_session: '5 hour session',
28
32
  five_min_load_avg: '5m load avg',
29
33
  github_api: 'GitHub API',
30
34
  limits_at_end: 'Limits at end',
@@ -76,6 +80,10 @@ const ENGLISH_LIMITS = {
76
80
  start: 'Start',
77
81
  subscription_ends: 'Subscription ends {{time}}',
78
82
  subscription_ends_in: 'Subscription ends in {{duration}} ({{time}})',
83
+ subscription_detail_ends: 'ends {{time}}',
84
+ subscription_detail_ends_in: 'ends in {{duration}}; {{time}}',
85
+ subscription_detail_trial_ends: 'trial ends {{time}}',
86
+ subscription_detail_trial_ends_in: 'trial ends in {{duration}}; {{time}}',
79
87
  subscription_status: 'Subscription: {{status}}',
80
88
  trial_ends: 'Trial ends {{time}}',
81
89
  trial_ends_in: 'Trial ends in {{duration}} ({{time}})',
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { CACHE_TTL, DEFAULT_CODEX_AUTH_PATH, DEFAULT_CREDENTIALS_PATH, decodeJwtPayload, getLimitCache, readCodexAuth, readCredentials } from './limits.lib.mjs';
13
- import { formatLocalizedRelativeTime, formatLocalizedResetTime, formatSubscriptionEnds, formatSubscriptionStatus, formatTrialEnds, resolveLimitLocale } from './limits-i18n.lib.mjs';
13
+ import { formatLocalizedRelativeTime, formatLocalizedResetTime, formatSubscriptionEnds, formatSubscriptionStatus, formatTrialEnds, lt, resolveLimitLocale } from './limits-i18n.lib.mjs';
14
14
 
15
15
  const PROFILE_API_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile';
16
16
 
@@ -32,6 +32,45 @@ export function formatSubscriptionLines(subscription, options = {}) {
32
32
  return line ? `${line}\n` : '';
33
33
  }
34
34
 
35
+ function humanizePlanType(planType, provider) {
36
+ if (!planType) return '';
37
+ const providerPrefix = provider === 'claude' ? /^claude[\s_-]*/i : /^(chatgpt|codex|openai)[\s_-]*/i;
38
+ const normalized = String(planType).trim().replace(providerPrefix, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
39
+ if (!normalized) return '';
40
+ return normalized
41
+ .split(' ')
42
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
43
+ .join(' ');
44
+ }
45
+
46
+ function formatSubscriptionDetail(subscription, options = {}) {
47
+ if (!subscription) return '';
48
+ const locale = resolveLimitLocale(options);
49
+ const buildDetail = (iso, inKey, atKey) => {
50
+ const resetTime = formatLocalizedResetTime(iso, true, { locale });
51
+ if (!resetTime) return '';
52
+ const duration = formatLocalizedRelativeTime(iso, { locale });
53
+ return duration ? lt(inKey, { duration, time: resetTime }, { locale }) : lt(atKey, { time: resetTime }, { locale });
54
+ };
55
+
56
+ if (subscription.endsAt) return buildDetail(subscription.endsAt, 'subscription_detail_ends_in', 'subscription_detail_ends');
57
+ if (subscription.trialEndsAt) return buildDetail(subscription.trialEndsAt, 'subscription_detail_trial_ends_in', 'subscription_detail_trial_ends');
58
+ return subscription.status ? String(subscription.status).trim() : '';
59
+ }
60
+
61
+ export function formatSubscriptionHeading(provider, subscription, options = {}) {
62
+ const locale = resolveLimitLocale(options);
63
+ const normalizedProvider = provider === 'claude' ? 'claude' : 'chatgpt';
64
+ const planType = subscription?.planType || options?.planType || null;
65
+ if (!subscription && !planType) return '';
66
+
67
+ const plan = humanizePlanType(planType, normalizedProvider);
68
+ const titleKey = normalizedProvider === 'claude' ? 'claude_subscription_title' : 'chatgpt_subscription_title';
69
+ const title = lt(titleKey, { plan: plan ? ` ${plan}` : '' }, { locale });
70
+ const detail = formatSubscriptionDetail(subscription, { locale });
71
+ return detail ? `${title} (${detail})` : title;
72
+ }
73
+
35
74
  /**
36
75
  * Get Claude subscription metadata.
37
76
  *