borgmcp 3.3.0 → 3.4.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/README.md +10 -3
- package/dist/agent-integration-health.d.ts +9 -5
- package/dist/agent-integration-health.d.ts.map +1 -1
- package/dist/agent-integration-health.js +104 -5
- package/dist/agent-integration-health.js.map +1 -1
- package/dist/claude.js +1 -0
- package/dist/claude.js.map +1 -1
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +1 -1
- package/dist/config-utils.js.map +1 -1
- package/dist/log-audit-core.d.ts +7 -0
- package/dist/log-audit-core.d.ts.map +1 -0
- package/dist/log-audit-core.js +80 -0
- package/dist/log-audit-core.js.map +1 -0
- package/dist/log-audit.d.ts +3 -3
- package/dist/log-audit.js +7 -110
- package/dist/log-audit.js.map +1 -1
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +6 -1
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-plugin.d.ts +47 -7
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +187 -23
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/regen-format.js +1 -1
- package/dist/regen-format.js.map +1 -1
- package/dist/resolved-cli-config.d.ts +1 -0
- package/dist/resolved-cli-config.d.ts.map +1 -1
- package/dist/resolved-cli-config.js +1 -0
- package/dist/resolved-cli-config.js.map +1 -1
- package/docs/LOCAL_SERVER.md +17 -4
- package/package.json +1 -1
- package/src/agent-integration-health.ts +121 -6
- package/src/claude.ts +1 -0
- package/src/config-utils.ts +1 -1
- package/src/log-audit-core.ts +81 -0
- package/src/log-audit.ts +6 -117
- package/src/opencode-drone.ts +6 -1
- package/src/opencode-plugin.ts +216 -22
- package/src/regen-format.ts +1 -1
- package/src/resolved-cli-config.ts +2 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const LOG_AUDIT_NUDGE = (count: number): string =>
|
|
2
|
+
`Heads up: ${count}+ state-changing tool calls since the last \`borg_log\` post. ` +
|
|
3
|
+
'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
|
|
4
|
+
"worth sharing), post to the cube log per your role's conventions before continuing.";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure transcript scan shared by the Claude hook and the OpenCode plugin.
|
|
8
|
+
* Accepts both Claude JSONL entries and OpenCode SDK message records.
|
|
9
|
+
*/
|
|
10
|
+
export function evaluateLogAudit(
|
|
11
|
+
entries: readonly any[],
|
|
12
|
+
renderNudge: (count: number) => string = (count) =>
|
|
13
|
+
`Heads up: ${count}+ state-changing tool calls since the last \`borg_log\` post. ` +
|
|
14
|
+
'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
|
|
15
|
+
"worth sharing), post to the cube log per your role's conventions before continuing.",
|
|
16
|
+
): string | null {
|
|
17
|
+
const materialTools = new Set([
|
|
18
|
+
'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash',
|
|
19
|
+
'edit', 'write', 'bash', 'apply_patch', 'exec_command',
|
|
20
|
+
'functions.exec_command', 'functions.apply_patch',
|
|
21
|
+
]);
|
|
22
|
+
const logTools = new Set(['mcp__borg__borg_log', 'borg_borg_log']);
|
|
23
|
+
const threshold = 3;
|
|
24
|
+
const maxScan = 400;
|
|
25
|
+
|
|
26
|
+
const role = (entry: any): unknown =>
|
|
27
|
+
entry?.type ?? entry?.role ?? entry?.info?.role;
|
|
28
|
+
const parts = (entry: any): any[] => {
|
|
29
|
+
const value = entry?.message?.content ?? entry?.content ?? entry?.parts ?? [];
|
|
30
|
+
return Array.isArray(value) ? value : [];
|
|
31
|
+
};
|
|
32
|
+
const isUserPrompt = (entry: any): boolean => {
|
|
33
|
+
if (role(entry) !== 'user') return false;
|
|
34
|
+
const content = entry?.message?.content ?? entry?.content ?? entry?.parts;
|
|
35
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
36
|
+
return Array.isArray(content) && content.some((part) =>
|
|
37
|
+
part?.type === 'text' && typeof part.text === 'string' && part.text.trim().length > 0,
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
const isAssistant = (entry: any): boolean => {
|
|
41
|
+
const value = role(entry);
|
|
42
|
+
return value === 'assistant' || value === 'response_item';
|
|
43
|
+
};
|
|
44
|
+
const toolName = (part: any): string | null => {
|
|
45
|
+
if (part?.type === 'tool_use' && typeof part.name === 'string') return part.name;
|
|
46
|
+
if ((part?.type === 'tool' || part?.type === 'tool_call') && typeof part.tool === 'string') {
|
|
47
|
+
return part.tool;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let index = entries.length - 1;
|
|
53
|
+
if (index >= 0 && isUserPrompt(entries[index])) index--;
|
|
54
|
+
let material = 0;
|
|
55
|
+
let scanned = 0;
|
|
56
|
+
for (; index >= 0 && scanned < maxScan; index--, scanned++) {
|
|
57
|
+
const entry = entries[index];
|
|
58
|
+
if (!isAssistant(entry)) continue;
|
|
59
|
+
|
|
60
|
+
const payload = entry?.payload;
|
|
61
|
+
const payloadTool =
|
|
62
|
+
payload?.type === 'function_call' || payload?.type === 'custom_tool_call'
|
|
63
|
+
? payload.name
|
|
64
|
+
: null;
|
|
65
|
+
if (typeof payloadTool === 'string') {
|
|
66
|
+
if (logTools.has(payloadTool)) return material >= threshold ? renderNudge(material) : null;
|
|
67
|
+
if (materialTools.has(payloadTool)) material++;
|
|
68
|
+
if (material >= threshold) return renderNudge(material);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const content = parts(entry);
|
|
72
|
+
for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {
|
|
73
|
+
const name = toolName(content[partIndex]);
|
|
74
|
+
if (!name) continue;
|
|
75
|
+
if (logTools.has(name)) return material >= threshold ? renderNudge(material) : null;
|
|
76
|
+
if (materialTools.has(name)) material++;
|
|
77
|
+
if (material >= threshold) return renderNudge(material);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
package/src/log-audit.ts
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* gets a turn of breathing room after each post.
|
|
17
17
|
*
|
|
18
18
|
* Stays generic — knows nothing about git, branches, or any project's
|
|
19
|
-
* conventions.
|
|
20
|
-
* small set of canonical mutating tool names.
|
|
21
|
-
* this project, silently exits.
|
|
19
|
+
* conventions. Its pure scan core recognizes the Borg log tool names used by
|
|
20
|
+
* Claude/Codex and OpenCode plus a small set of canonical mutating tool names.
|
|
21
|
+
* If no cube is active in this project, silently exits.
|
|
22
22
|
*
|
|
23
23
|
* Hook input arrives as JSON on stdin (Claude Code's standard hook
|
|
24
24
|
* contract). The relevant field is `transcript_path`.
|
|
@@ -28,92 +28,13 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
28
28
|
import { getActiveCube } from './cubes.js';
|
|
29
29
|
import { handleVersionFlag } from './version.js';
|
|
30
30
|
import { gateAllowsActivation } from './launch-gate.js';
|
|
31
|
-
|
|
32
|
-
const MATERIAL_TOOLS = new Set([
|
|
33
|
-
'Edit',
|
|
34
|
-
'Write',
|
|
35
|
-
'MultiEdit',
|
|
36
|
-
'NotebookEdit',
|
|
37
|
-
'Bash',
|
|
38
|
-
'apply_patch',
|
|
39
|
-
'exec_command',
|
|
40
|
-
'functions.exec_command',
|
|
41
|
-
'functions.apply_patch',
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const LOG_TOOL = 'mcp__borg__borg_log';
|
|
45
|
-
|
|
46
|
-
// Number of state-changing tool calls since the last borg_log that the
|
|
47
|
-
// drone is allowed before the audit nudges. 1 false-positives on
|
|
48
|
-
// diagnostic Bash (git status, ls, etc.); 3 has been comfortable in
|
|
49
|
-
// dogfooding — any substantive work crosses it within a turn or two.
|
|
50
|
-
const MATERIAL_THRESHOLD = 3;
|
|
51
|
-
|
|
52
|
-
// Cap on how many transcript entries we scan backwards before giving up.
|
|
53
|
-
// Sessions with thousands of turns still resolve in milliseconds at this
|
|
54
|
-
// bound, and anything truly old is no longer "the last span the drone
|
|
55
|
-
// failed to log."
|
|
56
|
-
const MAX_SCAN = 400;
|
|
31
|
+
import { evaluateLogAudit } from './log-audit-core.js';
|
|
57
32
|
|
|
58
33
|
interface HookInput {
|
|
59
34
|
transcript_path?: string;
|
|
60
35
|
cwd?: string;
|
|
61
36
|
}
|
|
62
37
|
|
|
63
|
-
function isUserPrompt(entry: any): boolean {
|
|
64
|
-
const type = entry?.type ?? entry?.role;
|
|
65
|
-
if (type !== 'user') return false;
|
|
66
|
-
const content = entry?.message?.content ?? entry?.content;
|
|
67
|
-
if (typeof content === 'string') return content.trim().length > 0;
|
|
68
|
-
if (!Array.isArray(content)) return false;
|
|
69
|
-
// A "real" user prompt has at least one text block. A tool_result-only
|
|
70
|
-
// user message is a continuation of an assistant span, not a prompt.
|
|
71
|
-
return content.some((b: any) => b?.type === 'text');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isAssistant(entry: any): boolean {
|
|
75
|
-
const type = entry?.type ?? entry?.role;
|
|
76
|
-
return type === 'assistant' || type === 'response_item';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
type ScanFinding =
|
|
80
|
-
| { kind: 'cooldown' } // hit a borg_log before threshold — suppress
|
|
81
|
-
| { kind: 'over_threshold'; count: number } // accumulated >= threshold
|
|
82
|
-
| { kind: 'under_threshold'; count: number }; // ran out of transcript
|
|
83
|
-
|
|
84
|
-
function scanAssistant(entry: any, state: { material: number; loggedRecently: boolean }): void {
|
|
85
|
-
const payload = entry?.payload;
|
|
86
|
-
const payloadToolName =
|
|
87
|
-
payload?.type === 'function_call' || payload?.type === 'custom_tool_call'
|
|
88
|
-
? payload.name
|
|
89
|
-
: null;
|
|
90
|
-
if (typeof payloadToolName === 'string') {
|
|
91
|
-
if (payloadToolName === LOG_TOOL) {
|
|
92
|
-
state.loggedRecently = true;
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (MATERIAL_TOOLS.has(payloadToolName)) state.material += 1;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const content = entry?.message?.content ?? entry?.content ?? [];
|
|
99
|
-
if (!Array.isArray(content)) return;
|
|
100
|
-
// Walk the blocks newest-first WITHIN the entry. The caller already
|
|
101
|
-
// visits entries newest-first. Counting forward within an entry would
|
|
102
|
-
// either miss post-log material work (if log is in the same entry as
|
|
103
|
-
// later material blocks) or inflate the count with pre-log work that
|
|
104
|
-
// the log already covered. Reversing here keeps "material since the
|
|
105
|
-
// last log" honest at block granularity.
|
|
106
|
-
for (let i = content.length - 1; i >= 0; i--) {
|
|
107
|
-
const block = content[i];
|
|
108
|
-
if (block?.type !== 'tool_use') continue;
|
|
109
|
-
if (block.name === LOG_TOOL) {
|
|
110
|
-
state.loggedRecently = true;
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (MATERIAL_TOOLS.has(block.name)) state.material += 1;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
38
|
async function readStdin(): Promise<string> {
|
|
118
39
|
if (process.stdin.isTTY) return '';
|
|
119
40
|
const chunks: Buffer[] = [];
|
|
@@ -158,40 +79,8 @@ async function main(): Promise<void> {
|
|
|
158
79
|
const lines = readFileSync(input.transcript_path, 'utf-8').split('\n').filter(Boolean);
|
|
159
80
|
if (lines.length === 0) return;
|
|
160
81
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// there, accumulate material tool calls until either we hit a borg_log
|
|
164
|
-
// (cooldown — suppress) or we cross MATERIAL_THRESHOLD (nudge). The
|
|
165
|
-
// scan stops after MAX_SCAN entries to bound work on huge sessions.
|
|
166
|
-
let i = lines.length - 1;
|
|
167
|
-
const tail = safeParse(lines[i]);
|
|
168
|
-
if (tail && isUserPrompt(tail)) i--;
|
|
169
|
-
|
|
170
|
-
const state = { material: 0, loggedRecently: false };
|
|
171
|
-
let scanned = 0;
|
|
172
|
-
for (; i >= 0 && scanned < MAX_SCAN; i--, scanned++) {
|
|
173
|
-
const entry = safeParse(lines[i]);
|
|
174
|
-
if (!entry) continue;
|
|
175
|
-
if (isAssistant(entry)) {
|
|
176
|
-
scanAssistant(entry, state);
|
|
177
|
-
// Threshold has primacy over cooldown: when both could fire on the
|
|
178
|
-
// same entry (e.g. an entry containing [log, Bash, Bash, Bash]
|
|
179
|
-
// where the reversed scan first counts 3 material blocks before
|
|
180
|
-
// hitting the log), we want the nudge — the post-log material
|
|
181
|
-
// work hasn't been logged yet.
|
|
182
|
-
if (state.material >= MATERIAL_THRESHOLD) {
|
|
183
|
-
process.stdout.write(
|
|
184
|
-
`Heads up: ${state.material}+ state-changing tool calls since the last \`borg_log\` post. ` +
|
|
185
|
-
'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
|
|
186
|
-
"worth sharing), post to the cube log per your role's conventions before continuing.\n"
|
|
187
|
-
);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (state.loggedRecently) return; // cooldown
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
// Reached MAX_SCAN or start of transcript without finding either a
|
|
194
|
-
// log call or enough material work. Silent.
|
|
82
|
+
const nudge = evaluateLogAudit(lines.map(safeParse).filter((entry) => entry !== null));
|
|
83
|
+
if (nudge) process.stdout.write(`${nudge}\n`);
|
|
195
84
|
}
|
|
196
85
|
|
|
197
86
|
function safeParse(line: string): any | null {
|
package/src/opencode-drone.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'crypto';
|
|
|
3
3
|
import { createServer } from 'node:net';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
|
+
import { OPENCODE_INJECTED_ENTRY_METADATA_KEY } from './opencode-plugin.js';
|
|
6
7
|
|
|
7
8
|
const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
|
|
8
9
|
function log(msg: string) {
|
|
@@ -511,7 +512,11 @@ async function deliverOpenCodeEntry(
|
|
|
511
512
|
let status: number | null = null;
|
|
512
513
|
try {
|
|
513
514
|
status = await promptSession(target.id, {
|
|
514
|
-
parts: [{
|
|
515
|
+
parts: [{
|
|
516
|
+
type: 'text',
|
|
517
|
+
text: delivery.text,
|
|
518
|
+
metadata: { [OPENCODE_INJECTED_ENTRY_METADATA_KEY]: true },
|
|
519
|
+
}],
|
|
515
520
|
});
|
|
516
521
|
} catch (err) {
|
|
517
522
|
log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -1,37 +1,231 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
3
|
+
import { evaluateLogAudit } from './log-audit-core.js';
|
|
4
|
+
import { borgHomeRoot, isCanonicalPath } from './private-root.js';
|
|
5
|
+
import { getPackageVersion } from './version.js';
|
|
6
|
+
|
|
7
|
+
export const OPENCODE_COMPATIBILITY = {
|
|
8
|
+
// Empirically pinned on 2026-08-09. OpenCode 1.18.15 loads the default
|
|
9
|
+
// function -> Hooks object shape while the installed SDK uses the 1.17.18
|
|
10
|
+
// path/query/body client call shape. TextPart.metadata exists, persists
|
|
11
|
+
// through history/compaction/reload, and is hidden from the TUI and model.
|
|
12
|
+
// Do not replace either contract without a new live compatibility measurement.
|
|
13
|
+
opencode: '1.18.15',
|
|
14
|
+
sdk: '1.17.18',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const COMPACT_FALLBACK =
|
|
18
|
+
'## Borg Cube\nYou are in a Borg MCP multi-agent coordination cube. ' +
|
|
19
|
+
'Use MCP tool borg_regen to get full context and recent activity.';
|
|
20
|
+
export const OPENCODE_INJECTED_ENTRY_METADATA_KEY = 'borgOpenCodeInjectedEntry';
|
|
21
|
+
export const OPENCODE_RECOVERY_METADATA_KEY = 'borgOpenCodeSessionOrientation';
|
|
22
|
+
const PLUGIN_REL_PATH = path.join('.config', 'opencode', 'plugins', 'borg-orient.js');
|
|
23
|
+
|
|
24
|
+
export interface OpenCodePluginCoreDeps {
|
|
25
|
+
defer(task: () => Promise<void>): void;
|
|
26
|
+
wait(milliseconds: number): Promise<void>;
|
|
27
|
+
listMessages(sessionID: string): Promise<any[]>;
|
|
28
|
+
renderOrientation(source: 'clear' | 'compact'): Promise<string>;
|
|
29
|
+
submitPrompt(
|
|
30
|
+
sessionID: string,
|
|
31
|
+
text: string,
|
|
32
|
+
recoveryVersion: string,
|
|
33
|
+
shouldSubmit: () => boolean,
|
|
34
|
+
): Promise<boolean>;
|
|
35
|
+
audit(messages: readonly any[]): string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OpenCodePluginCoreOptions {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
pluginVersion: string;
|
|
41
|
+
recoveryMetadataKey: string;
|
|
42
|
+
injectedEntryMetadataKey: string;
|
|
43
|
+
kickoffPollAttempts: number;
|
|
44
|
+
confirmationPollAttempts: number;
|
|
45
|
+
pollDelayMs: number;
|
|
46
|
+
compactFallback: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pure, dependency-injected behavior core. Its emitted JavaScript function
|
|
50
|
+
* body is also embedded in the installed self-contained plugin. */
|
|
51
|
+
export function createOpenCodePluginCore(
|
|
52
|
+
deps: OpenCodePluginCoreDeps,
|
|
53
|
+
options: OpenCodePluginCoreOptions,
|
|
54
|
+
) {
|
|
55
|
+
const claimedSessions = new Set<string>();
|
|
56
|
+
const humanPromptSessions = new Set<string>();
|
|
57
|
+
const textParts = (message: any): any[] => Array.isArray(message?.parts)
|
|
58
|
+
? message.parts.filter((part: any) => part?.type === 'text' && typeof part.text === 'string')
|
|
59
|
+
: [];
|
|
60
|
+
const isInjectedEntry = (message: any): boolean => {
|
|
61
|
+
const parts = textParts(message);
|
|
62
|
+
return message?.info?.role === 'user' &&
|
|
63
|
+
parts[0]?.metadata?.[options.injectedEntryMetadataKey] === true;
|
|
64
|
+
};
|
|
65
|
+
const isOwnedRecovery = (message: any): boolean => {
|
|
66
|
+
const parts = textParts(message);
|
|
67
|
+
return message?.info?.role === 'user' &&
|
|
68
|
+
parts[0]?.metadata?.[options.recoveryMetadataKey] === options.pluginVersion;
|
|
69
|
+
};
|
|
70
|
+
const hasRecoveryBlocker = (sessionID: string, messages: readonly any[]): boolean =>
|
|
71
|
+
humanPromptSessions.has(sessionID) || messages.some((message) =>
|
|
72
|
+
isOwnedRecovery(message) ||
|
|
73
|
+
(message?.info?.role === 'user' && !isInjectedEntry(message)));
|
|
74
|
+
|
|
75
|
+
const recoverNewSession = async (sessionID: string): Promise<void> => {
|
|
76
|
+
try {
|
|
77
|
+
for (let attempt = 0; attempt < options.kickoffPollAttempts; attempt++) {
|
|
78
|
+
const messages = await deps.listMessages(sessionID);
|
|
79
|
+
if (hasRecoveryBlocker(sessionID, messages)) return;
|
|
80
|
+
if (attempt + 1 < options.kickoffPollAttempts) await deps.wait(options.pollDelayMs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const orientation = (await deps.renderOrientation('clear')).trim();
|
|
84
|
+
if (!orientation) return;
|
|
85
|
+
const beforeSubmit = await deps.listMessages(sessionID);
|
|
86
|
+
if (hasRecoveryBlocker(sessionID, beforeSubmit)) return;
|
|
87
|
+
|
|
88
|
+
const submitted = await deps.submitPrompt(
|
|
89
|
+
sessionID,
|
|
90
|
+
orientation,
|
|
91
|
+
options.pluginVersion,
|
|
92
|
+
() => !hasRecoveryBlocker(sessionID, []),
|
|
18
93
|
);
|
|
94
|
+
if (!submitted) return;
|
|
95
|
+
// promptAsync is not idempotent. Confirmation may retry, submission may not.
|
|
96
|
+
for (let attempt = 0; attempt < options.confirmationPollAttempts; attempt++) {
|
|
97
|
+
const messages = await deps.listMessages(sessionID);
|
|
98
|
+
if (messages.some(isOwnedRecovery)) return;
|
|
99
|
+
if (attempt + 1 < options.confirmationPollAttempts) await deps.wait(options.pollDelayMs);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// The plugin is best-effort. Never block OpenCode session creation.
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
event: async ({ event }: any): Promise<void> => {
|
|
108
|
+
if (!options.enabled || event?.type !== 'session.created') return;
|
|
109
|
+
const sessionID = event?.properties?.info?.id;
|
|
110
|
+
if (typeof sessionID !== 'string' || claimedSessions.has(sessionID)) return;
|
|
111
|
+
claimedSessions.add(sessionID);
|
|
112
|
+
deps.defer(() => recoverNewSession(sessionID));
|
|
113
|
+
},
|
|
114
|
+
'experimental.session.compacting': async (
|
|
115
|
+
_input: { sessionID: string },
|
|
116
|
+
output: { context: string[] },
|
|
117
|
+
): Promise<void> => {
|
|
118
|
+
if (!options.enabled) return;
|
|
119
|
+
try {
|
|
120
|
+
const orientation = (await deps.renderOrientation('compact')).trim();
|
|
121
|
+
output.context.push(orientation || options.compactFallback);
|
|
122
|
+
} catch {
|
|
123
|
+
output.context.push(options.compactFallback);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
'chat.message': async (
|
|
127
|
+
input: { sessionID: string },
|
|
128
|
+
output: { message: unknown; parts: any[] },
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
if (!options.enabled) return;
|
|
131
|
+
const current = { info: { role: 'user' }, parts: [...output.parts] };
|
|
132
|
+
if (!isInjectedEntry(current) && !isOwnedRecovery(current)) {
|
|
133
|
+
// chat.message fires before the user message is persisted. Record the
|
|
134
|
+
// human turn synchronously so recovery cannot race that short gap.
|
|
135
|
+
humanPromptSessions.add(input.sessionID);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const history = await deps.listMessages(input.sessionID);
|
|
139
|
+
const nudge = deps.audit([...history, current]);
|
|
140
|
+
if (nudge) output.parts.push({ type: 'text', text: nudge });
|
|
141
|
+
} catch {
|
|
142
|
+
// Audit is advisory and must never block a prompt.
|
|
143
|
+
}
|
|
19
144
|
},
|
|
20
145
|
};
|
|
21
146
|
}
|
|
147
|
+
|
|
148
|
+
export function buildBorgPluginSource(version: string): string {
|
|
149
|
+
const marker = `borgmcp-opencode-plugin:${version};opencode=${OPENCODE_COMPATIBILITY.opencode};sdk=${OPENCODE_COMPATIBILITY.sdk};textpart-metadata=exists+persisted+tui-hidden+model-hidden`;
|
|
150
|
+
return `// ${marker}
|
|
151
|
+
// Generated by borgmcp. Self-contained; do not edit.
|
|
152
|
+
const createCore = ${createOpenCodePluginCore.toString()};
|
|
153
|
+
const evaluateAudit = ${evaluateLogAudit.toString()};
|
|
154
|
+
export default async function (ctx) {
|
|
155
|
+
const runRegen = async (source) => {
|
|
156
|
+
const input = JSON.stringify({ source });
|
|
157
|
+
const result = await ctx.$\`printf '%s' \${input} | borg-regen\`.quiet().nothrow();
|
|
158
|
+
return result.exitCode === 0 ? result.stdout.toString('utf8') : '';
|
|
159
|
+
};
|
|
160
|
+
return createCore({
|
|
161
|
+
defer: (task) => { setTimeout(() => { void task(); }, 0); },
|
|
162
|
+
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
163
|
+
listMessages: async (sessionID) => {
|
|
164
|
+
const result = await ctx.client.session.messages({
|
|
165
|
+
path: { id: sessionID },
|
|
166
|
+
query: { directory: ctx.directory },
|
|
167
|
+
});
|
|
168
|
+
return Array.isArray(result.data) ? result.data : [];
|
|
169
|
+
},
|
|
170
|
+
renderOrientation: runRegen,
|
|
171
|
+
submitPrompt: async (sessionID, text, recoveryVersion, shouldSubmit) => {
|
|
172
|
+
if (!shouldSubmit()) return false;
|
|
173
|
+
await ctx.client.session.promptAsync({
|
|
174
|
+
path: { id: sessionID },
|
|
175
|
+
query: { directory: ctx.directory },
|
|
176
|
+
body: { parts: [{
|
|
177
|
+
type: 'text',
|
|
178
|
+
text,
|
|
179
|
+
metadata: { [${JSON.stringify(OPENCODE_RECOVERY_METADATA_KEY)}]: recoveryVersion },
|
|
180
|
+
}] },
|
|
181
|
+
});
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
184
|
+
audit: (messages) => evaluateAudit(messages),
|
|
185
|
+
}, {
|
|
186
|
+
...${JSON.stringify({
|
|
187
|
+
pluginVersion: version,
|
|
188
|
+
recoveryMetadataKey: OPENCODE_RECOVERY_METADATA_KEY,
|
|
189
|
+
injectedEntryMetadataKey: OPENCODE_INJECTED_ENTRY_METADATA_KEY,
|
|
190
|
+
kickoffPollAttempts: 6,
|
|
191
|
+
confirmationPollAttempts: 6,
|
|
192
|
+
pollDelayMs: 200,
|
|
193
|
+
compactFallback: COMPACT_FALLBACK,
|
|
194
|
+
})},
|
|
195
|
+
enabled: process.env.BORG_SESSION === '1',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
22
198
|
`;
|
|
199
|
+
}
|
|
23
200
|
|
|
24
|
-
const
|
|
201
|
+
export const BORG_PLUGIN_SOURCE = buildBorgPluginSource(getPackageVersion());
|
|
25
202
|
|
|
26
|
-
export function
|
|
27
|
-
|
|
203
|
+
export function openCodePluginPath(homeDir: string = borgHomeRoot()): string {
|
|
204
|
+
return path.join(homeDir, PLUGIN_REL_PATH);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function installBorgPlugin(options: {
|
|
208
|
+
homeDir?: string;
|
|
209
|
+
version?: string;
|
|
210
|
+
} = {}): void {
|
|
211
|
+
const pluginPath = openCodePluginPath(options.homeDir);
|
|
212
|
+
const source = buildBorgPluginSource(options.version ?? getPackageVersion());
|
|
28
213
|
try {
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
if (
|
|
214
|
+
if (!isCanonicalPath(pluginPath)) return;
|
|
215
|
+
try {
|
|
216
|
+
if (fs.lstatSync(pluginPath).isSymbolicLink()) return;
|
|
217
|
+
if (fs.readFileSync(pluginPath, 'utf-8') === source) return;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) return;
|
|
32
220
|
}
|
|
33
221
|
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
34
|
-
|
|
222
|
+
if (!isCanonicalPath(pluginPath)) return;
|
|
223
|
+
try {
|
|
224
|
+
if (fs.lstatSync(pluginPath).isSymbolicLink()) return;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) return;
|
|
227
|
+
}
|
|
228
|
+
fs.writeFileSync(pluginPath, source, 'utf-8');
|
|
35
229
|
} catch {
|
|
36
230
|
// Best-effort — plugin is an optimization, not a requirement.
|
|
37
231
|
}
|
package/src/regen-format.ts
CHANGED
|
@@ -190,7 +190,7 @@ export function formatLeanOrientation(args: {
|
|
|
190
190
|
'\n_(`/clear` cleared Claude\'s conversation — re-arm the inbox Monitor now.)_',
|
|
191
191
|
'_Quiet-clear fallback: if a later turn follows silence, inspect `borg_stream-status` + `borg_roster`; call `borg_regen mode="full"`, drain `borg_read-log unread_only=true`, then re-arm the Monitor._\n',
|
|
192
192
|
].join('\n')
|
|
193
|
-
: ''
|
|
193
|
+
: '\n_(OpenCode started a new session; Borg supplied this orientation automatically.)_\n'
|
|
194
194
|
: '';
|
|
195
195
|
return [
|
|
196
196
|
`# Cube: ${cubeName} — ${droneLabel}`,
|
|
@@ -7,6 +7,7 @@ export interface ResolvedCliConfigDeps {
|
|
|
7
7
|
addClaudeUserPromptSubmitHook(): void;
|
|
8
8
|
addCodexSessionStartHook(): void;
|
|
9
9
|
addCodexUserPromptSubmitHook(): void;
|
|
10
|
+
installOpenCodePlugin(): void;
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
13
|
* Apply only the integration writes for the CLI the launcher resolved.
|
|
@@ -37,4 +38,5 @@ export function configureResolvedCli(
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
deps.ensureMcp('opencode');
|
|
41
|
+
deps.installOpenCodePlugin();
|
|
40
42
|
}
|