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.
- package/dist/cli/commands/manager/agent-monitoring.d.ts +2 -2
- package/dist/cli/commands/manager/agent-monitoring.d.ts.map +1 -1
- package/dist/cli/commands/manager/agent-monitoring.js +10 -28
- package/dist/cli/commands/manager/agent-monitoring.js.map +1 -1
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js +3 -2
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js.map +1 -1
- package/dist/cli/commands/manager/escalation-handler.d.ts +1 -0
- package/dist/cli/commands/manager/escalation-handler.d.ts.map +1 -1
- package/dist/cli/commands/manager/escalation-handler.js +46 -17
- package/dist/cli/commands/manager/escalation-handler.js.map +1 -1
- package/dist/cli/commands/manager/escalation-handler.test.js +55 -1
- package/dist/cli/commands/manager/escalation-handler.test.js.map +1 -1
- package/dist/cli/commands/manager/handoff-recovery.d.ts.map +1 -1
- package/dist/cli/commands/manager/handoff-recovery.js +4 -15
- package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts +1 -0
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +50 -0
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/index.test.js +127 -14
- package/dist/cli/commands/manager/index.test.js.map +1 -1
- package/dist/cli/commands/manager/manager-utils.d.ts +1 -1
- package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -1
- package/dist/cli/commands/manager/manager-utils.js +5 -18
- package/dist/cli/commands/manager/manager-utils.js.map +1 -1
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +4 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/context-files/index.test.js +1 -0
- package/dist/context-files/index.test.js.map +1 -1
- package/dist/tmux/manager.d.ts +9 -0
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +21 -0
- package/dist/tmux/manager.js.map +1 -1
- package/dist/tmux/manager.test.js +41 -0
- package/dist/tmux/manager.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/manager/agent-monitoring.ts +10 -52
- package/src/cli/commands/manager/auto-reject-comment-only-reviews.test.ts +3 -2
- package/src/cli/commands/manager/escalation-handler.test.ts +85 -0
- package/src/cli/commands/manager/escalation-handler.ts +52 -27
- package/src/cli/commands/manager/handoff-recovery.ts +4 -34
- package/src/cli/commands/manager/index.test.ts +149 -16
- package/src/cli/commands/manager/index.ts +59 -0
- package/src/cli/commands/manager/manager-utils.ts +5 -38
- package/src/config/schema.ts +4 -0
- package/src/context-files/index.test.ts +1 -0
- package/src/tmux/manager.test.ts +49 -0
- 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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
109
|
-
await
|
|
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('../../../
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
vi.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
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
|
-
|
|
30
|
+
_ctx: ManagerCheckContext,
|
|
64
31
|
sessionName: string,
|
|
65
32
|
message: string
|
|
66
33
|
): Promise<void> {
|
|
67
|
-
|
|
68
|
-
await
|
|
69
|
-
|
|
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 {
|
package/src/config/schema.ts
CHANGED
|
@@ -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:
|
package/src/tmux/manager.test.ts
CHANGED
|
@@ -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');
|