hungry-ghost-hive 0.52.2 → 0.54.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.
Files changed (50) hide show
  1. package/dist/cli/commands/manager/agent-monitoring.d.ts +2 -2
  2. package/dist/cli/commands/manager/agent-monitoring.d.ts.map +1 -1
  3. package/dist/cli/commands/manager/agent-monitoring.js +10 -28
  4. package/dist/cli/commands/manager/agent-monitoring.js.map +1 -1
  5. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js +3 -2
  6. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js.map +1 -1
  7. package/dist/cli/commands/manager/escalation-handler.d.ts +1 -0
  8. package/dist/cli/commands/manager/escalation-handler.d.ts.map +1 -1
  9. package/dist/cli/commands/manager/escalation-handler.js +46 -17
  10. package/dist/cli/commands/manager/escalation-handler.js.map +1 -1
  11. package/dist/cli/commands/manager/escalation-handler.test.js +55 -1
  12. package/dist/cli/commands/manager/escalation-handler.test.js.map +1 -1
  13. package/dist/cli/commands/manager/handoff-recovery.d.ts.map +1 -1
  14. package/dist/cli/commands/manager/handoff-recovery.js +4 -15
  15. package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
  16. package/dist/cli/commands/manager/index.d.ts +1 -0
  17. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  18. package/dist/cli/commands/manager/index.js +50 -0
  19. package/dist/cli/commands/manager/index.js.map +1 -1
  20. package/dist/cli/commands/manager/index.test.js +127 -14
  21. package/dist/cli/commands/manager/index.test.js.map +1 -1
  22. package/dist/cli/commands/manager/manager-utils.d.ts +1 -1
  23. package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -1
  24. package/dist/cli/commands/manager/manager-utils.js +5 -18
  25. package/dist/cli/commands/manager/manager-utils.js.map +1 -1
  26. package/dist/config/schema.d.ts +8 -0
  27. package/dist/config/schema.d.ts.map +1 -1
  28. package/dist/config/schema.js +4 -0
  29. package/dist/config/schema.js.map +1 -1
  30. package/dist/context-files/index.test.js +1 -0
  31. package/dist/context-files/index.test.js.map +1 -1
  32. package/dist/tmux/manager.d.ts +9 -0
  33. package/dist/tmux/manager.d.ts.map +1 -1
  34. package/dist/tmux/manager.js +21 -0
  35. package/dist/tmux/manager.js.map +1 -1
  36. package/dist/tmux/manager.test.js +41 -0
  37. package/dist/tmux/manager.test.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/commands/manager/agent-monitoring.ts +10 -52
  40. package/src/cli/commands/manager/auto-reject-comment-only-reviews.test.ts +3 -2
  41. package/src/cli/commands/manager/escalation-handler.test.ts +85 -0
  42. package/src/cli/commands/manager/escalation-handler.ts +52 -27
  43. package/src/cli/commands/manager/handoff-recovery.ts +4 -34
  44. package/src/cli/commands/manager/index.test.ts +149 -16
  45. package/src/cli/commands/manager/index.ts +59 -0
  46. package/src/cli/commands/manager/manager-utils.ts +5 -38
  47. package/src/config/schema.ts +4 -0
  48. package/src/context-files/index.test.ts +1 -0
  49. package/src/tmux/manager.test.ts +49 -0
  50. package/src/tmux/manager.ts +23 -0
@@ -7,6 +7,7 @@ import {
7
7
  buildHumanApprovalReason,
8
8
  buildInterruptionRecoveryPrompt,
9
9
  buildRateLimitRecoveryPrompt,
10
+ extractQuestionContent,
10
11
  handleEscalationAndNudge,
11
12
  } from './escalation-handler.js';
12
13
 
@@ -28,12 +29,62 @@ vi.mock('../../../tmux/manager.js', () => ({
28
29
  getHiveSessions: vi.fn(),
29
30
  sendMessageWithConfirmation: vi.fn(),
30
31
  sendToTmuxSession: vi.fn(),
32
+ sendBtwToTmuxSession: vi.fn(),
31
33
  autoApprovePermission: vi.fn(),
32
34
  forceBypassMode: vi.fn(),
33
35
  isManagerRunning: vi.fn(),
34
36
  stopManager: vi.fn(),
35
37
  }));
36
38
 
39
+ describe('extractQuestionContent', () => {
40
+ it('extracts lines above the interactive prompt as the question body', () => {
41
+ const output = [
42
+ 'Some previous output',
43
+ '',
44
+ 'Should I delete the existing migration files?',
45
+ 'This will remove all local schema changes.',
46
+ '› Yes / No',
47
+ ].join('\n');
48
+
49
+ expect(extractQuestionContent(output)).toBe(
50
+ 'Should I delete the existing migration files? This will remove all local schema changes.'
51
+ );
52
+ });
53
+
54
+ it('falls back to prompt line text when no lines precede the prompt', () => {
55
+ const output = '› Do you want to continue?';
56
+ expect(extractQuestionContent(output)).toBe('Do you want to continue?');
57
+ });
58
+
59
+ it('falls back to prompt line when preceding lines are empty', () => {
60
+ const output = '\n\n\n› Proceed with deployment?';
61
+ expect(extractQuestionContent(output)).toBe('Proceed with deployment?');
62
+ });
63
+
64
+ it('returns null when no interactive prompt line is found', () => {
65
+ const output = 'Agent is working on the task...';
66
+ expect(extractQuestionContent(output)).toBeNull();
67
+ });
68
+
69
+ it('strips the › prefix from the prompt line fallback', () => {
70
+ const output = '› What branch should I use?';
71
+ expect(extractQuestionContent(output)).toBe('What branch should I use?');
72
+ });
73
+
74
+ it('handles > prefix as well as ›', () => {
75
+ const output = '> Run git push now?';
76
+ expect(extractQuestionContent(output)).toBe('Run git push now?');
77
+ });
78
+
79
+ it('stops collecting question lines at the first empty line above the prompt', () => {
80
+ const output = ['Old unrelated output', '', 'Question: should I proceed?', '› Yes / No'].join(
81
+ '\n'
82
+ );
83
+
84
+ expect(extractQuestionContent(output)).toBe('Question: should I proceed?');
85
+ });
86
+ });
87
+
37
88
  describe('buildHumanApprovalReason', () => {
38
89
  it('should include option-2 guidance for codex permission menus', () => {
39
90
  const output = `
@@ -85,6 +136,40 @@ $ git restore --worktree
85
136
  expect(reason).toContain('Action: Answer the question in the agent session');
86
137
  });
87
138
 
139
+ it('extracts actual question text from terminal output for ASKING_QUESTION state', () => {
140
+ const output = [
141
+ 'Some working output',
142
+ '',
143
+ 'Should I overwrite the existing config file?',
144
+ '› Yes / No',
145
+ ].join('\n');
146
+
147
+ const reason = buildHumanApprovalReason(
148
+ 'hive-junior-team-1',
149
+ 'Asking a question - needs response',
150
+ AgentState.ASKING_QUESTION,
151
+ 'claude',
152
+ output
153
+ );
154
+
155
+ expect(reason).toContain('Should I overwrite the existing config file?');
156
+ expect(reason).not.toContain('Asking a question - needs response');
157
+ expect(reason).toContain('Action: Answer the question in the agent session');
158
+ });
159
+
160
+ it('falls back to generic waitingReason when question extraction fails for ASKING_QUESTION', () => {
161
+ const reason = buildHumanApprovalReason(
162
+ 'hive-junior-team-1',
163
+ 'Asking a question - needs response',
164
+ AgentState.ASKING_QUESTION,
165
+ 'claude',
166
+ 'No interactive prompt here'
167
+ );
168
+
169
+ expect(reason).toContain('Asking a question - needs response');
170
+ expect(reason).toContain('Action: Answer the question in the agent session');
171
+ });
172
+
88
173
  it('should include recovery guidance for declined state', () => {
89
174
  const reason = buildHumanApprovalReason(
90
175
  'hive-senior-team-1',
@@ -17,6 +17,7 @@ import {
17
17
  createManagerNudgeEnvelope,
18
18
  describeAgentState,
19
19
  isRateLimitPrompt,
20
+ sendBtwToTmuxSession,
20
21
  sendToTmuxSession,
21
22
  submitManagerNudgeWithVerification,
22
23
  type CLITool,
@@ -33,11 +34,51 @@ const rateLimitRecoveryAttempts = new Map<string, number>();
33
34
  const INTERRUPTION_PROMPT_PATTERN =
34
35
  /conversation interrupted|tell the model what to do differently|hit [`'"]?\/feedback[`'"]? to report the issue/i;
35
36
  const INTERRUPTION_WINDOW_LINES = 80;
37
+ const QUESTION_EXTRACTION_WINDOW_LINES = 40;
38
+ const INTERACTIVE_PROMPT_LINE_PATTERN = /^\s*(?:›|>)\s+\S.+$/m;
36
39
 
37
40
  function getRecentPaneOutput(output: string, lineCount: number): string {
38
41
  return output.split('\n').slice(-lineCount).join('\n');
39
42
  }
40
43
 
44
+ export function extractQuestionContent(output: string): string | null {
45
+ const recentOutput = getRecentPaneOutput(output, QUESTION_EXTRACTION_WINDOW_LINES);
46
+ const lines = recentOutput.split('\n');
47
+
48
+ // Find the last interactive prompt line index
49
+ let promptLineIndex = -1;
50
+ for (let i = lines.length - 1; i >= 0; i--) {
51
+ if (INTERACTIVE_PROMPT_LINE_PATTERN.test(lines[i])) {
52
+ promptLineIndex = i;
53
+ break;
54
+ }
55
+ }
56
+
57
+ // Extract the prompt line text (strip leading › or > and whitespace)
58
+ const promptLine =
59
+ promptLineIndex >= 0 ? lines[promptLineIndex].replace(/^\s*(?:›|>)\s*/, '').trim() : null;
60
+
61
+ // Collect non-empty lines before the prompt as the question body
62
+ if (promptLineIndex > 0) {
63
+ const questionLines: string[] = [];
64
+ for (let i = promptLineIndex - 1; i >= 0; i--) {
65
+ const line = lines[i].trim();
66
+ if (line.length === 0) break;
67
+ questionLines.unshift(line);
68
+ }
69
+ if (questionLines.length > 0) {
70
+ return questionLines.join(' ');
71
+ }
72
+ }
73
+
74
+ // Fall back to the prompt line content itself
75
+ if (promptLine && promptLine.length > 0) {
76
+ return promptLine;
77
+ }
78
+
79
+ return null;
80
+ }
81
+
41
82
  function isInterruptionPrompt(output: string): boolean {
42
83
  const recentOutput = getRecentPaneOutput(output, INTERRUPTION_WINDOW_LINES);
43
84
  return INTERRUPTION_PROMPT_PATTERN.test(recentOutput);
@@ -101,7 +142,14 @@ export function buildHumanApprovalReason(
101
142
  output: string
102
143
  ): string {
103
144
  const actionHint = getActionHintForBlockedState(state, cliTool, output);
104
- return `Approval required (${cliTool}) in ${sessionName}: ${waitingReason || 'Unknown question'}. Action: ${actionHint}`;
145
+ let reason = waitingReason || 'Unknown question';
146
+ if (state === AgentState.ASKING_QUESTION) {
147
+ const extracted = extractQuestionContent(output);
148
+ if (extracted) {
149
+ reason = extracted;
150
+ }
151
+ }
152
+ return `Approval required (${cliTool}) in ${sessionName}: ${reason}. Action: ${actionHint}`;
105
153
  }
106
154
 
107
155
  export function buildInterruptionRecoveryPrompt(
@@ -415,33 +463,10 @@ export async function handleEscalationAndNudge(
415
463
  ctx.counters.escalationsCreated++;
416
464
  ctx.escalatedSessions.add(sessionName);
417
465
 
466
+ // Send auto-recovery reminder via /btw to avoid interrupting the agent
418
467
  const reminder = buildAutoRecoveryReminder(sessionName, agentCliTool);
419
- const nudge = createManagerNudgeEnvelope(reminder);
420
- await sendToTmuxSession(sessionName, nudge.text);
421
- console.log(
422
- chalk.gray(
423
- ` Nudge ${nudge.nudgeId}: double-checking Enter delivery after nudge (verification loop enabled)`
424
- )
425
- );
426
- const submitResult = await submitManagerNudgeWithVerification(sessionName, nudge.nudgeId);
427
- ctx.counters.nudgeEnterPresses =
428
- (ctx.counters.nudgeEnterPresses ?? 0) + submitResult.enterPresses;
429
- ctx.counters.nudgeEnterRetries =
430
- (ctx.counters.nudgeEnterRetries ?? 0) + submitResult.retryEnters;
431
- if (!submitResult.confirmed) {
432
- ctx.counters.nudgeSubmitUnconfirmed = (ctx.counters.nudgeSubmitUnconfirmed ?? 0) + 1;
433
- console.log(
434
- chalk.yellow(
435
- ` Nudge ${nudge.nudgeId}: unable to confirm Enter delivery after ${submitResult.checks} check(s), ${submitResult.enterPresses} Enter keypress(es)`
436
- )
437
- );
438
- } else {
439
- console.log(
440
- chalk.gray(
441
- ` Nudge ${nudge.nudgeId}: Enter delivery confirmed after ${submitResult.checks} check(s), ${submitResult.enterPresses} Enter keypress(es)`
442
- )
443
- );
444
- }
468
+ await sendBtwToTmuxSession(sessionName, reminder);
469
+ console.log(chalk.gray(` Escalation nudge delivered via /btw to ${sessionName}`));
445
470
 
446
471
  console.log(chalk.red(` ESCALATION: ${sessionName} needs human input`));
447
472
  verboseLog(ctx, `escalationCheck: ${sessionName} action=create_escalation`);
@@ -10,12 +10,7 @@ import { updateRequirement } from '../../../db/queries/requirements.js';
10
10
  import { getStoriesByStatus, updateStory } from '../../../db/queries/stories.js';
11
11
  import { isTmuxSessionRunning } from '../../../tmux/manager.js';
12
12
  import { getTechLeadSessionName } from '../../../utils/instance.js';
13
- import {
14
- createManagerNudgeEnvelope,
15
- sendToTmuxSession,
16
- submitManagerNudgeWithVerification,
17
- type CLITool,
18
- } from './agent-monitoring.js';
13
+ import { sendBtwToTmuxSession, type CLITool } from './agent-monitoring.js';
19
14
  import type { ManagerCheckContext, PlanningHandoffTracking } from './types.js';
20
15
  import { PROACTIVE_HANDOFF_RETRY_DELAY_MS } from './types.js';
21
16
 
@@ -105,34 +100,9 @@ async function nudgeTechLeadForStalledHandoff(
105
100
  # Please move stories from estimated -> planned and run:
106
101
  # hive assign`;
107
102
 
108
- const nudge = createManagerNudgeEnvelope(nudgeMessage);
109
- await sendToTmuxSession(techLeadInfo.sessionName, nudge.text);
110
- console.log(
111
- chalk.gray(
112
- ` Nudge ${nudge.nudgeId}: double-checking Enter delivery after nudge (verification loop enabled)`
113
- )
114
- );
115
- const submitResult = await submitManagerNudgeWithVerification(
116
- techLeadInfo.sessionName,
117
- nudge.nudgeId
118
- );
119
- ctx.counters.nudgeEnterPresses =
120
- (ctx.counters.nudgeEnterPresses ?? 0) + submitResult.enterPresses;
121
- ctx.counters.nudgeEnterRetries = (ctx.counters.nudgeEnterRetries ?? 0) + submitResult.retryEnters;
122
- if (!submitResult.confirmed) {
123
- ctx.counters.nudgeSubmitUnconfirmed = (ctx.counters.nudgeSubmitUnconfirmed ?? 0) + 1;
124
- console.log(
125
- chalk.yellow(
126
- ` Nudge ${nudge.nudgeId}: unable to confirm Enter delivery after ${submitResult.checks} check(s), ${submitResult.enterPresses} Enter keypress(es)`
127
- )
128
- );
129
- } else {
130
- console.log(
131
- chalk.gray(
132
- ` Nudge ${nudge.nudgeId}: Enter delivery confirmed after ${submitResult.checks} check(s), ${submitResult.enterPresses} Enter keypress(es)`
133
- )
134
- );
135
- }
103
+ // Use /btw to deliver nudge non-interruptively
104
+ await sendBtwToTmuxSession(techLeadInfo.sessionName, nudgeMessage);
105
+ console.log(chalk.gray(` Handoff nudge delivered via /btw to ${techLeadInfo.sessionName}`));
136
106
  verboseLog(
137
107
  ctx,
138
108
  `handoff: nudged tech-lead session=${techLeadInfo.sessionName} requirement=${requirementLabel} estimated=${estimatedCount}`
@@ -1,28 +1,51 @@
1
1
  // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
- import type { DatabaseProvider } from '../../../db/provider.js';
4
+ import type { DatabaseProvider, WritableDatabaseProvider } from '../../../db/provider.js';
5
+ import { getAllAgents } from '../../../db/queries/agents.js';
6
+ import { getAllPendingMessages, markMessagesRead } from '../../../db/queries/messages.js';
5
7
  import { getApprovedPullRequests, updatePullRequest } from '../../../db/queries/pull-requests.js';
6
8
 
7
9
  // Mock the functions we're testing with
8
10
  vi.mock('../../../db/queries/pull-requests.js');
9
11
  vi.mock('../../../db/queries/stories.js');
10
12
  vi.mock('../../../db/queries/logs.js');
11
- vi.mock('../../../tmux/manager.js', () => ({
12
- sendMessageWithConfirmation: vi.fn(),
13
- getHiveSessions: vi.fn(),
14
- sendToTmuxSession: vi.fn(),
15
- sendEnterToTmuxSession: vi.fn(),
16
- captureTmuxPane: vi.fn(),
17
- isManagerRunning: vi.fn(),
18
- stopManager: vi.fn(),
19
- killTmuxSession: vi.fn(),
20
- }));
21
- vi.mock('../../../utils/cli-commands.js', () => ({
22
- getAvailableCommands: vi.fn(() => ({
23
- msgReply: (id: string, msg: string, session: string) =>
24
- `hive msg reply ${id} "${msg}" --to ${session}`,
25
- })),
13
+ vi.mock('../../../db/queries/agents.js');
14
+ vi.mock('../../../db/queries/messages.js');
15
+ vi.mock('../../../tmux/manager.js', async importOriginal => {
16
+ const actual = await importOriginal<typeof import('../../../tmux/manager.js')>();
17
+ return {
18
+ ...actual,
19
+ sendMessageWithConfirmation: vi.fn(),
20
+ getHiveSessions: vi.fn(),
21
+ sendToTmuxSession: vi.fn(),
22
+ sendEnterToTmuxSession: vi.fn(),
23
+ captureTmuxPane: vi.fn(),
24
+ isManagerRunning: vi.fn(),
25
+ stopManager: vi.fn(),
26
+ killTmuxSession: vi.fn(),
27
+ };
28
+ });
29
+ vi.mock('../../../utils/cli-commands.js', async importOriginal => {
30
+ const actual = await importOriginal<typeof import('../../../utils/cli-commands.js')>();
31
+ return {
32
+ ...actual,
33
+ getAvailableCommands: vi.fn(() => ({
34
+ msgReply: (id: string, msg: string, session: string) =>
35
+ `hive msg reply ${id} "${msg}" --to ${session}`,
36
+ })),
37
+ };
38
+ });
39
+ vi.mock('./agent-monitoring.js', async importOriginal => {
40
+ const actual = await importOriginal<typeof import('./agent-monitoring.js')>();
41
+ return {
42
+ ...actual,
43
+ forwardMessages: vi.fn(),
44
+ };
45
+ });
46
+ vi.mock('../../../utils/with-hive-context.js', () => ({
47
+ withHiveContext: vi.fn(),
48
+ withHiveRoot: vi.fn(),
26
49
  }));
27
50
 
28
51
  describe('Auto-merge PRs', () => {
@@ -265,3 +288,113 @@ describe('Stuck reminder deferral', () => {
265
288
  expect(result).toBe(false);
266
289
  });
267
290
  });
291
+
292
+ describe('runFastMessageCheck', () => {
293
+ beforeEach(() => {
294
+ vi.clearAllMocks();
295
+ });
296
+
297
+ function mockDbContext(save = vi.fn()) {
298
+ const provider = {} as WritableDatabaseProvider;
299
+ return {
300
+ db: {
301
+ provider,
302
+ save,
303
+ close: vi.fn(),
304
+ runMigrations: vi.fn(),
305
+ db: {} as never,
306
+ },
307
+ root: '/test',
308
+ paths: {} as never,
309
+ };
310
+ }
311
+
312
+ it('does nothing when there are no pending messages', async () => {
313
+ const { withHiveContext } = await import('../../../utils/with-hive-context.js');
314
+ const { forwardMessages } = await import('./agent-monitoring.js');
315
+
316
+ vi.mocked(withHiveContext).mockImplementation(async fn => fn(mockDbContext()));
317
+ vi.mocked(getAllPendingMessages).mockResolvedValue([]);
318
+
319
+ const { runFastMessageCheck } = await import('./index.js');
320
+ await runFastMessageCheck();
321
+
322
+ expect(forwardMessages).not.toHaveBeenCalled();
323
+ expect(markMessagesRead).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('forwards pending messages to target sessions', async () => {
327
+ const { withHiveContext } = await import('../../../utils/with-hive-context.js');
328
+ const { forwardMessages } = await import('./agent-monitoring.js');
329
+
330
+ const mockSave = vi.fn();
331
+ vi.mocked(withHiveContext).mockImplementation(async fn => fn(mockDbContext(mockSave)));
332
+
333
+ const pendingMessages = [
334
+ {
335
+ id: 'msg-1',
336
+ to_session: 'hive-agent-abc',
337
+ from_session: 'tech-lead',
338
+ subject: 'Test',
339
+ body: 'Hello',
340
+ status: 'pending',
341
+ created_at: new Date().toISOString(),
342
+ read_at: null,
343
+ },
344
+ ];
345
+ vi.mocked(getAllPendingMessages).mockResolvedValue(pendingMessages as never);
346
+ vi.mocked(getAllAgents).mockResolvedValue([
347
+ { id: 'agent-abc', tmux_session: null, cli_tool: 'claude' } as never,
348
+ ]);
349
+ vi.mocked(forwardMessages).mockResolvedValue(undefined);
350
+
351
+ const { runFastMessageCheck } = await import('./index.js');
352
+ await runFastMessageCheck();
353
+
354
+ expect(forwardMessages).toHaveBeenCalledWith('hive-agent-abc', pendingMessages, 'claude');
355
+ expect(markMessagesRead).toHaveBeenCalledWith(expect.anything(), ['msg-1']);
356
+ expect(mockSave).toHaveBeenCalled();
357
+ });
358
+
359
+ it('uses claude as default cli_tool when agent not found', async () => {
360
+ const { withHiveContext } = await import('../../../utils/with-hive-context.js');
361
+ const { forwardMessages } = await import('./agent-monitoring.js');
362
+
363
+ vi.mocked(withHiveContext).mockImplementation(async fn => fn(mockDbContext()));
364
+
365
+ const pendingMessages = [
366
+ {
367
+ id: 'msg-2',
368
+ to_session: 'unknown-session',
369
+ from_session: 'user',
370
+ subject: null,
371
+ body: 'Hi',
372
+ status: 'pending',
373
+ created_at: new Date().toISOString(),
374
+ read_at: null,
375
+ },
376
+ ];
377
+ vi.mocked(getAllPendingMessages).mockResolvedValue(pendingMessages as never);
378
+ vi.mocked(getAllAgents).mockResolvedValue([]);
379
+ vi.mocked(forwardMessages).mockResolvedValue(undefined);
380
+
381
+ const { runFastMessageCheck } = await import('./index.js');
382
+ await runFastMessageCheck();
383
+
384
+ expect(forwardMessages).toHaveBeenCalledWith('unknown-session', pendingMessages, 'claude');
385
+ });
386
+
387
+ it('does not trigger full agent scan (scanAgentSessions not called)', async () => {
388
+ const { withHiveContext } = await import('../../../utils/with-hive-context.js');
389
+ const { getHiveSessions } = await import('../../../tmux/manager.js');
390
+
391
+ vi.mocked(withHiveContext).mockImplementation(async fn => fn(mockDbContext()));
392
+ vi.mocked(getAllPendingMessages).mockResolvedValue([]);
393
+
394
+ const { runFastMessageCheck } = await import('./index.js');
395
+ await runFastMessageCheck();
396
+
397
+ // Full scan would call getHiveSessions — verify it is not called
398
+ expect(getHiveSessions).not.toHaveBeenCalled();
399
+ });
400
+ });
@@ -353,6 +353,13 @@ managerCommand
353
353
  setInterval(() => {
354
354
  void runCheck();
355
355
  }, slowInterval);
356
+
357
+ // Fast message-check path: forward pending messages without full agent scan
358
+ const messagePollInterval = config.manager.message_poll_interval_ms;
359
+ console.log(chalk.gray(` Fast message polling every ${messagePollInterval / 1000}s`));
360
+ setInterval(() => {
361
+ void runFastMessageCheck(verbose);
362
+ }, messagePollInterval);
356
363
  } else if (releaseLock) {
357
364
  await releaseLock();
358
365
  if (clusterRuntime) {
@@ -518,6 +525,58 @@ managerCommand
518
525
  });
519
526
  });
520
527
 
528
+ export async function runFastMessageCheck(verbose = false): Promise<void> {
529
+ verboseLog(verbose, 'fastMessageCheck: start');
530
+ try {
531
+ await withHiveContext(async ({ db }) => {
532
+ const allPendingMessages = await getAllPendingMessages(db.provider);
533
+ if (allPendingMessages.length === 0) {
534
+ verboseLog(verbose, 'fastMessageCheck: no pending messages');
535
+ return;
536
+ }
537
+
538
+ verboseLog(verbose, `fastMessageCheck: ${allPendingMessages.length} pending message(s)`);
539
+
540
+ const messagesBySession = new Map<string, typeof allPendingMessages>();
541
+ for (const msg of allPendingMessages) {
542
+ if (!messagesBySession.has(msg.to_session)) {
543
+ messagesBySession.set(msg.to_session, []);
544
+ }
545
+ messagesBySession.get(msg.to_session)!.push(msg);
546
+ }
547
+
548
+ const allAgents = await getAllAgents(db.provider);
549
+ const agentBySession = new Map<string, AgentRow>();
550
+ for (const agent of allAgents) {
551
+ agentBySession.set(`hive-${agent.id}`, agent);
552
+ if (agent.tmux_session) {
553
+ agentBySession.set(agent.tmux_session, agent);
554
+ }
555
+ }
556
+
557
+ const forwardedIds: string[] = [];
558
+ for (const [sessionName, messages] of messagesBySession) {
559
+ const agent = agentBySession.get(sessionName);
560
+ const cliTool: CLITool = (agent?.cli_tool as CLITool) || 'claude';
561
+ verboseLog(
562
+ verbose,
563
+ `fastMessageCheck: forwarding ${messages.length} message(s) to ${sessionName}`
564
+ );
565
+ await forwardMessages(sessionName, messages, cliTool);
566
+ forwardedIds.push(...messages.map(m => m.id));
567
+ }
568
+
569
+ if (forwardedIds.length > 0) {
570
+ markMessagesRead(db.provider, forwardedIds);
571
+ db.save();
572
+ verboseLog(verbose, `fastMessageCheck: marked ${forwardedIds.length} message(s) as read`);
573
+ }
574
+ });
575
+ } catch (err) {
576
+ verboseLog(verbose, `fastMessageCheck: error ${String(err)}`);
577
+ }
578
+ }
579
+
521
580
  async function managerCheck(
522
581
  root: string,
523
582
  config?: HiveConfig,
@@ -2,11 +2,7 @@
2
2
 
3
3
  import chalk from 'chalk';
4
4
  import type { HiveConfig } from '../../../config/schema.js';
5
- import { sendToTmuxSession } from '../../../tmux/manager.js';
6
- import {
7
- createManagerNudgeEnvelope,
8
- submitManagerNudgeWithVerification,
9
- } from './agent-monitoring.js';
5
+ import { sendBtwToTmuxSession } from '../../../tmux/manager.js';
10
6
  import type { ManagerCheckContext } from './types.js';
11
7
 
12
8
  const DEFAULT_SCREEN_STATIC_INACTIVITY_THRESHOLD_MS = 10 * 60 * 1000;
@@ -30,43 +26,14 @@ export function formatDuration(ms: number): string {
30
26
  return `${minutes}m ${seconds}s`;
31
27
  }
32
28
 
33
- async function submitManagerNudge(
34
- ctx: ManagerCheckContext,
35
- sessionName: string,
36
- nudgeId: string
37
- ): Promise<void> {
38
- console.log(
39
- chalk.gray(
40
- ` Nudge ${nudgeId}: double-checking Enter delivery after nudge (verification loop enabled)`
41
- )
42
- );
43
- const result = await submitManagerNudgeWithVerification(sessionName, nudgeId);
44
- ctx.counters.nudgeEnterPresses = (ctx.counters.nudgeEnterPresses ?? 0) + result.enterPresses;
45
- ctx.counters.nudgeEnterRetries = (ctx.counters.nudgeEnterRetries ?? 0) + result.retryEnters;
46
- if (!result.confirmed) {
47
- ctx.counters.nudgeSubmitUnconfirmed = (ctx.counters.nudgeSubmitUnconfirmed ?? 0) + 1;
48
- console.log(
49
- chalk.yellow(
50
- ` Nudge ${nudgeId}: unable to confirm Enter delivery after ${result.checks} check(s), ${result.enterPresses} Enter keypress(es)`
51
- )
52
- );
53
- return;
54
- }
55
- console.log(
56
- chalk.gray(
57
- ` Nudge ${nudgeId}: Enter delivery confirmed after ${result.checks} check(s), ${result.enterPresses} Enter keypress(es)`
58
- )
59
- );
60
- }
61
-
62
29
  export async function sendManagerNudge(
63
- ctx: ManagerCheckContext,
30
+ _ctx: ManagerCheckContext,
64
31
  sessionName: string,
65
32
  message: string
66
33
  ): Promise<void> {
67
- const envelope = createManagerNudgeEnvelope(message);
68
- await sendToTmuxSession(sessionName, envelope.text);
69
- await submitManagerNudge(ctx, sessionName, envelope.nudgeId);
34
+ // Use /btw for non-interrupting nudge delivery
35
+ await sendBtwToTmuxSession(sessionName, message);
36
+ console.log(chalk.gray(` Nudge delivered via /btw to ${sessionName}`));
70
37
  }
71
38
 
72
39
  export function getScreenStaticInactivityThresholdMs(config?: HiveConfig): number {
@@ -250,6 +250,8 @@ const ManagerConfigSchema = z.object({
250
250
  auditor_interval_ms: z.number().int().positive().default(300000),
251
251
  // Whether auditor agent is enabled (false falls back to nudge behavior)
252
252
  auditor_enabled: z.boolean().default(true),
253
+ // Fast message poll interval for web UI interactions (ms, default 10s)
254
+ message_poll_interval_ms: z.number().int().positive().default(10000),
253
255
  });
254
256
 
255
257
  // Merge queue configuration
@@ -573,6 +575,8 @@ manager:
573
575
  auditor_interval_ms: 300000
574
576
  # Whether auditor agent is enabled (false falls back to nudge behavior)
575
577
  auditor_enabled: true
578
+ # Fast message poll interval for web UI interactions (ms, default 10s)
579
+ message_poll_interval_ms: 10000
576
580
 
577
581
  # Merge queue configuration
578
582
  merge_queue:
@@ -228,6 +228,7 @@ describe('context-files module', () => {
228
228
  tech_lead_max_age_hours: 6,
229
229
  auditor_interval_ms: 300000,
230
230
  auditor_enabled: true,
231
+ message_poll_interval_ms: 10000,
231
232
  },
232
233
  logging: { level: 'info', retention_days: 30 },
233
234
  cluster: {
@@ -68,6 +68,55 @@ describe('sendMessageWithConfirmation', () => {
68
68
  });
69
69
  });
70
70
 
71
+ describe('sendBtwToTmuxSession', () => {
72
+ beforeEach(() => {
73
+ vi.clearAllMocks();
74
+ });
75
+
76
+ afterEach(() => {
77
+ vi.clearAllMocks();
78
+ });
79
+
80
+ it('should be exported as a function', async () => {
81
+ const { sendBtwToTmuxSession } = await import('./manager.js');
82
+ expect(typeof sendBtwToTmuxSession).toBe('function');
83
+ });
84
+
85
+ it('should have the expected function signature', async () => {
86
+ const { sendBtwToTmuxSession } = await import('./manager.js');
87
+ const sig = sendBtwToTmuxSession.toString();
88
+ expect(sig).toContain('sessionName');
89
+ expect(sig).toContain('message');
90
+ });
91
+
92
+ it('should prefix messages with /btw', async () => {
93
+ const { sendBtwToTmuxSession } = await import('./manager.js');
94
+ const sig = sendBtwToTmuxSession.toString();
95
+ expect(sig).toContain('/btw ');
96
+ });
97
+
98
+ it('should not clear input before sending', async () => {
99
+ const { sendBtwToTmuxSession } = await import('./manager.js');
100
+ const sig = sendBtwToTmuxSession.toString();
101
+ // Should NOT contain Escape or C-u clearing logic
102
+ expect(sig).not.toContain('Escape');
103
+ expect(sig).not.toContain('C-u');
104
+ });
105
+
106
+ it('should handle multi-line messages via paste buffer', async () => {
107
+ const { sendBtwToTmuxSession } = await import('./manager.js');
108
+ const sig = sendBtwToTmuxSession.toString();
109
+ expect(sig).toContain('set-buffer');
110
+ expect(sig).toContain('paste-buffer');
111
+ });
112
+
113
+ it('should send Enter after the message', async () => {
114
+ const { sendBtwToTmuxSession } = await import('./manager.js');
115
+ const sig = sendBtwToTmuxSession.toString();
116
+ expect(sig).toContain('C-m');
117
+ });
118
+ });
119
+
71
120
  describe('tmux shell command hardening', () => {
72
121
  it('shellEscapeArg should safely escape single quotes', async () => {
73
122
  const { shellEscapeArg } = await import('./manager.js');