@wenrwa/marketplace-cli 0.7.8 → 0.8.2
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/__tests__/bridge-activity-tail.test.js +16 -0
- package/dist/__tests__/bridge-attach-paste-engine.test.d.ts +1 -0
- package/dist/__tests__/bridge-attach-paste-engine.test.js +108 -0
- package/dist/__tests__/bridge-bundle-installer-opencode.test.d.ts +1 -0
- package/dist/__tests__/bridge-bundle-installer-opencode.test.js +69 -0
- package/dist/__tests__/bridge-context-engine.test.d.ts +1 -0
- package/dist/__tests__/bridge-context-engine.test.js +112 -0
- package/dist/__tests__/bridge-context-manifest-frame.test.js +19 -0
- package/dist/__tests__/bridge-crew-translator.test.d.ts +1 -0
- package/dist/__tests__/bridge-crew-translator.test.js +103 -0
- package/dist/__tests__/bridge-install-bundle-frame.test.js +24 -0
- package/dist/__tests__/bridge-loopback-opencode-open.test.d.ts +1 -0
- package/dist/__tests__/bridge-loopback-opencode-open.test.js +51 -0
- package/dist/__tests__/bridge-opencode-binary.test.d.ts +1 -0
- package/dist/__tests__/bridge-opencode-binary.test.js +128 -0
- package/dist/__tests__/bridge-opencode-launch.test.d.ts +1 -0
- package/dist/__tests__/bridge-opencode-launch.test.js +216 -0
- package/dist/__tests__/bridge-relay-pucc14.test.js +28 -0
- package/dist/__tests__/bridge-server.test.js +32 -0
- package/dist/__tests__/opencode-config-parity.test.d.ts +1 -0
- package/dist/__tests__/opencode-config-parity.test.js +85 -0
- package/dist/__tests__/opencode-plugin.test.d.ts +1 -0
- package/dist/__tests__/opencode-plugin.test.js +197 -0
- package/dist/__tests__/opencode-title.test.d.ts +1 -0
- package/dist/__tests__/opencode-title.test.js +99 -0
- package/dist/__tests__/opencode-transcript-reader.test.d.ts +1 -0
- package/dist/__tests__/opencode-transcript-reader.test.js +156 -0
- package/dist/bridge/activityTail.d.ts +7 -0
- package/dist/bridge/activityTail.js +1 -0
- package/dist/bridge/agentLaunch.d.ts +28 -0
- package/dist/bridge/agentLaunch.js +49 -0
- package/dist/bridge/attachmentWriter.d.ts +16 -0
- package/dist/bridge/attachmentWriter.js +24 -1
- package/dist/bridge/bundleInstaller.d.ts +3 -0
- package/dist/bridge/bundleInstaller.js +22 -8
- package/dist/bridge/claudeTranscriptReader.d.ts +7 -1
- package/dist/bridge/claudeTranscriptReader.js +12 -2
- package/dist/bridge/contextFile.d.ts +20 -0
- package/dist/bridge/contextFile.js +30 -0
- package/dist/bridge/contextWriter.d.ts +10 -1
- package/dist/bridge/contextWriter.js +29 -9
- package/dist/bridge/crewTranslator.d.ts +41 -0
- package/dist/bridge/crewTranslator.js +129 -0
- package/dist/bridge/launchSupervisor.d.ts +4 -0
- package/dist/bridge/opencodeBinary.d.ts +47 -0
- package/dist/bridge/opencodeBinary.js +161 -0
- package/dist/bridge/opencodeConfig.d.ts +43 -0
- package/dist/bridge/opencodeConfig.js +109 -0
- package/dist/bridge/opencodePlugin.d.ts +13 -0
- package/dist/bridge/opencodePlugin.js +54 -0
- package/dist/bridge/opencodeTranscriptReader.d.ts +24 -0
- package/dist/bridge/opencodeTranscriptReader.js +178 -0
- package/dist/bridge/relayClient.d.ts +34 -1
- package/dist/bridge/relayClient.js +33 -8
- package/dist/bridge/screenshotAttach.d.ts +2 -0
- package/dist/bridge/screenshotAttach.js +3 -1
- package/dist/bridge/server.d.ts +15 -0
- package/dist/bridge/server.js +21 -3
- package/dist/bridge/sessionManager.d.ts +5 -0
- package/dist/bridge/sessionManager.js +21 -1
- package/dist/commands/bridge.js +76 -9
- package/hooks/opencode-plugin.mjs +186 -0
- package/package.json +1 -1
|
@@ -38,6 +38,8 @@ exports.sniffImageExt = sniffImageExt;
|
|
|
38
38
|
exports.safeAttachmentStem = safeAttachmentStem;
|
|
39
39
|
exports.writeAttachment = writeAttachment;
|
|
40
40
|
exports.attachmentRootFor = attachmentRootFor;
|
|
41
|
+
exports.pastePayloadFor = pastePayloadFor;
|
|
42
|
+
exports.pasteIfOpencode = pasteIfOpencode;
|
|
41
43
|
exports.handleAttachFrame = handleAttachFrame;
|
|
42
44
|
/**
|
|
43
45
|
* Terminal attach — save a screenshot NEXT TO the agent that will read it.
|
|
@@ -154,6 +156,24 @@ function attachmentRootFor(sessions, sessionId) {
|
|
|
154
156
|
return null;
|
|
155
157
|
return sessions.cwdOf(sessionId) ?? os.homedir();
|
|
156
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* oc-16 — what to type into the PTY after the file is written, per engine.
|
|
161
|
+
* Claude Code: nothing (the pane types the path itself; bytes unchanged).
|
|
162
|
+
* OpenCode: the path as a BRACKETED PASTE with no Enter — its TUI detects an
|
|
163
|
+
* image file path on the paste event (a bare typed path is not guaranteed to
|
|
164
|
+
* attach) and the user keeps typing. Spaces are backslash-escaped.
|
|
165
|
+
*/
|
|
166
|
+
function pastePayloadFor(engine, filePath) {
|
|
167
|
+
if (engine !== 'opencode')
|
|
168
|
+
return null;
|
|
169
|
+
return `\x1b[200~${filePath.replace(/ /g, '\\ ')}\x1b[201~`;
|
|
170
|
+
}
|
|
171
|
+
/** Paste the path into an OpenCode session's prompt; true when it was written
|
|
172
|
+
* (`pasted` in the reply tells the pane not to type the path itself). */
|
|
173
|
+
function pasteIfOpencode(sessions, sessionId, filePath) {
|
|
174
|
+
const paste = pastePayloadFor(sessions.engineOf?.(sessionId), filePath);
|
|
175
|
+
return paste !== null && sessions.writeTo?.(sessionId, paste) === true;
|
|
176
|
+
}
|
|
157
177
|
/**
|
|
158
178
|
* The whole `attach-file` frame → `file-attached` reply, shared verbatim by
|
|
159
179
|
* the local server and the relay client so the two lanes cannot drift.
|
|
@@ -168,5 +188,8 @@ function handleAttachFrame(frame, sessions, deps = exports.realAttachmentDeps) {
|
|
|
168
188
|
if (!root)
|
|
169
189
|
return { ...head, ok: false, reason: 'no-session' };
|
|
170
190
|
const result = writeAttachment({ root, name: typeof frame.name === 'string' ? frame.name : 'image', dataBase64: typeof frame.data === 'string' ? frame.data : '' }, deps);
|
|
171
|
-
|
|
191
|
+
if (!result.ok)
|
|
192
|
+
return { ...head, ok: false, reason: result.reason };
|
|
193
|
+
const pasted = pasteIfOpencode(sessions, frame.sessionId, result.path);
|
|
194
|
+
return { ...head, ok: true, path: result.path, bytes: result.bytes, ...(pasted ? { pasted: true } : {}) };
|
|
172
195
|
}
|
|
@@ -27,6 +27,8 @@ export interface BundleInstallerDeps {
|
|
|
27
27
|
readFile?: (filePath: string) => string;
|
|
28
28
|
remove?: (filePath: string) => void;
|
|
29
29
|
}
|
|
30
|
+
/** oc-18 — which engine's file layout this bundle is installed as. */
|
|
31
|
+
export type BundleEngine = 'claude' | 'opencode';
|
|
30
32
|
export type SkipReason = 'unsafe' | 'unmanaged';
|
|
31
33
|
export interface InstallBundleResult {
|
|
32
34
|
/** The absolute workspace checkout root, or null if the workspaceId itself was unsafe. */
|
|
@@ -53,4 +55,5 @@ export declare function isSafeWorkspaceId(workspaceId: string): boolean;
|
|
|
53
55
|
export declare function installBundle(bundle: {
|
|
54
56
|
workspaceId: string;
|
|
55
57
|
files: FileMap;
|
|
58
|
+
engine?: BundleEngine | string;
|
|
56
59
|
}, deps: BundleInstallerDeps): InstallBundleResult;
|
|
@@ -65,6 +65,7 @@ exports.installBundle = installBundle;
|
|
|
65
65
|
* the crew files.
|
|
66
66
|
*/
|
|
67
67
|
const path = __importStar(require("path"));
|
|
68
|
+
const crewTranslator_1 = require("./crewTranslator");
|
|
68
69
|
/**
|
|
69
70
|
* The marker every forged crew member carries. Its presence is what
|
|
70
71
|
* distinguishes a file this installer put there from one the pilot wrote by
|
|
@@ -89,11 +90,24 @@ function isSafeWorkspaceId(workspaceId) {
|
|
|
89
90
|
workspaceId !== '.' &&
|
|
90
91
|
workspaceId !== '..');
|
|
91
92
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Only the crew-managed surface is writable: `.claude/**` + `CLAUDE.md`, plus
|
|
95
|
+
* (oc-18, engine=opencode only) `.opencode/agent/**` + `AGENTS.md`. The widening
|
|
96
|
+
* is per-call: a Claude install can never write OpenCode paths and vice versa.
|
|
97
|
+
*/
|
|
98
|
+
function isManaged(normalizedRel, engine) {
|
|
99
|
+
if (normalizedRel === 'CLAUDE.md' || normalizedRel.startsWith('.claude/'))
|
|
100
|
+
return true;
|
|
101
|
+
return engine === 'opencode' && (normalizedRel === 'AGENTS.md' || normalizedRel.startsWith(`${crewTranslator_1.OPENCODE_AGENT_DIR}/`));
|
|
95
102
|
}
|
|
96
103
|
function installBundle(bundle, deps) {
|
|
104
|
+
// oc-18: anything but an explicit 'opencode' is the Claude path, unchanged.
|
|
105
|
+
const engine = bundle.engine === 'opencode' ? 'opencode' : 'claude';
|
|
106
|
+
const agentsDirRel = engine === 'opencode' ? crewTranslator_1.OPENCODE_AGENT_DIR : AGENTS_DIR_REL;
|
|
107
|
+
// Translate BEFORE the path checks so the translated paths are the ones vetted.
|
|
108
|
+
const sourceFiles = engine === 'opencode'
|
|
109
|
+
? (0, crewTranslator_1.translateCrewBundleForOpencode)(bundle.files ?? {})
|
|
110
|
+
: (bundle.files ?? {});
|
|
97
111
|
const written = [];
|
|
98
112
|
const skipped = [];
|
|
99
113
|
const pruned = [];
|
|
@@ -106,7 +120,7 @@ function installBundle(bundle, deps) {
|
|
|
106
120
|
const workspaceRoot = deps.workspaceRoot
|
|
107
121
|
? path.posix.resolve(deps.workspaceRoot)
|
|
108
122
|
: path.posix.resolve(deps.reposBaseDir, bundle.workspaceId);
|
|
109
|
-
for (const [rawRel, content] of Object.entries(
|
|
123
|
+
for (const [rawRel, content] of Object.entries(sourceFiles)) {
|
|
110
124
|
// Reject obvious escapes up front: empty, null byte, absolute (posix or
|
|
111
125
|
// Windows-drive). Absolute paths would make path.posix.resolve ignore the
|
|
112
126
|
// workspace root entirely.
|
|
@@ -130,7 +144,7 @@ function installBundle(bundle, deps) {
|
|
|
130
144
|
continue;
|
|
131
145
|
}
|
|
132
146
|
// Managed surface only.
|
|
133
|
-
if (!isManaged(normalized)) {
|
|
147
|
+
if (!isManaged(normalized, engine)) {
|
|
134
148
|
skipped.push({ path: rawRel, reason: 'unmanaged' });
|
|
135
149
|
continue;
|
|
136
150
|
}
|
|
@@ -157,14 +171,14 @@ function installBundle(bundle, deps) {
|
|
|
157
171
|
- and any error reading the directory abandons pruning entirely — an
|
|
158
172
|
install that wrote its files must not fail because cleanup could not.
|
|
159
173
|
*/
|
|
160
|
-
const installedAgents = new Set(written.filter((rel) => rel.startsWith(`${
|
|
174
|
+
const installedAgents = new Set(written.filter((rel) => rel.startsWith(`${agentsDirRel}/`)));
|
|
161
175
|
if (installedAgents.size > 0 && deps.listDir && deps.readFile && deps.remove) {
|
|
162
|
-
const agentsDir = path.posix.resolve(workspaceRoot,
|
|
176
|
+
const agentsDir = path.posix.resolve(workspaceRoot, agentsDirRel);
|
|
163
177
|
try {
|
|
164
178
|
for (const name of deps.listDir(agentsDir)) {
|
|
165
179
|
if (!name.endsWith('.md'))
|
|
166
180
|
continue;
|
|
167
|
-
const rel = `${
|
|
181
|
+
const rel = `${agentsDirRel}/${name}`;
|
|
168
182
|
if (installedAgents.has(rel))
|
|
169
183
|
continue;
|
|
170
184
|
const full = path.posix.resolve(agentsDir, name);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ClaudeSessionHistoryDeps } from './claudeSessionHistory';
|
|
2
|
+
import { type OpencodeTranscriptDeps } from './opencodeTranscriptReader';
|
|
2
3
|
/** 20MB — generous for a jsonl transcript (a real multi-hour session
|
|
3
4
|
* comfortably fits) while still refusing a runaway file rather than
|
|
4
5
|
* holding it all in memory and shipping it down one WS frame. */
|
|
@@ -8,12 +9,17 @@ export type ReadAgentTranscriptResult = {
|
|
|
8
9
|
content: string;
|
|
9
10
|
} | {
|
|
10
11
|
ok: false;
|
|
11
|
-
reason: 'not-found' | 'invalid-agent-id' | 'too-large';
|
|
12
|
+
reason: 'not-found' | 'invalid-agent-id' | 'too-large' | 'transcript-unavailable';
|
|
12
13
|
};
|
|
13
14
|
export interface ClaudeTranscriptReaderDeps extends ClaudeSessionHistoryDeps {
|
|
14
15
|
existsSync?: (p: string) => boolean;
|
|
15
16
|
statSize?: (p: string) => number;
|
|
16
17
|
readFileUtf8?: (p: string) => string;
|
|
18
|
+
/** oc-17: which reader. Absent -> Claude's jsonl when present, else the agent's OpenCode DB. */
|
|
19
|
+
engine?: 'claude_code' | 'opencode';
|
|
20
|
+
/** oc-17: OpenCode session id to read (absent -> the newest root session). */
|
|
21
|
+
opencodeSessionId?: string;
|
|
22
|
+
opencode?: OpencodeTranscriptDeps;
|
|
17
23
|
}
|
|
18
24
|
/** Read ONE agent's transcript out of `<reposBaseDir>/<workspaceId>`'s
|
|
19
25
|
* Claude Code project directory. Never writes, never caches — a single
|
|
@@ -70,6 +70,7 @@ const fs = __importStar(require("fs"));
|
|
|
70
70
|
const path = __importStar(require("path"));
|
|
71
71
|
const claudeSessionHistory_1 = require("./claudeSessionHistory");
|
|
72
72
|
const bundleInstaller_1 = require("./bundleInstaller");
|
|
73
|
+
const opencodeTranscriptReader_1 = require("./opencodeTranscriptReader");
|
|
73
74
|
/** 20MB — generous for a jsonl transcript (a real multi-hour session
|
|
74
75
|
* comfortably fits) while still refusing a runaway file rather than
|
|
75
76
|
* holding it all in memory and shipping it down one WS frame. */
|
|
@@ -101,8 +102,17 @@ function readAgentTranscript(reposBaseDir, workspaceId, agentId, deps = {}) {
|
|
|
101
102
|
const workspaceRoot = path.posix.resolve(reposBaseDir, workspaceId);
|
|
102
103
|
const projectDir = path.join((0, claudeSessionHistory_1.claudeConfigDir)(deps), 'projects', (0, claudeSessionHistory_1.claudeProjectSlug)(workspaceRoot));
|
|
103
104
|
const transcriptPath = path.join(projectDir, `${agentId}.jsonl`);
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
const readOpencode = () => (0, opencodeTranscriptReader_1.readOpencodeTranscript)(agentId, deps.opencodeSessionId, { existsSync, ...deps.opencode });
|
|
106
|
+
if (deps.engine === 'opencode')
|
|
107
|
+
return readOpencode();
|
|
108
|
+
if (!existsSync(transcriptPath)) {
|
|
109
|
+
if (deps.engine === 'claude_code')
|
|
110
|
+
return { ok: false, reason: 'not-found' };
|
|
111
|
+
// oc-17: no Claude jsonl -- an OpenCode agent's history lives in its own DB.
|
|
112
|
+
return existsSync((0, opencodeTranscriptReader_1.opencodeDbPath)(agentId, { existsSync, ...deps.opencode }))
|
|
113
|
+
? readOpencode()
|
|
114
|
+
: { ok: false, reason: 'not-found' };
|
|
115
|
+
}
|
|
106
116
|
let size;
|
|
107
117
|
try {
|
|
108
118
|
size = statSize(transcriptPath);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc-23 — which project-instructions file an engine reads. ONE mapping, shared by
|
|
3
|
+
* every writer that puts HiveOp context into a checkout.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code reads CLAUDE.md. OpenCode reads AGENTS.md, and falls back to
|
|
6
|
+
* CLAUDE.md only while AGENTS.md is absent (oc-18 notes), so a region written
|
|
7
|
+
* to CLAUDE.md alone is invisible to an OpenCode agent the moment a crew
|
|
8
|
+
* bundle or anything else creates AGENTS.md.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CLAUDE_CONTEXT_FILE = "CLAUDE.md";
|
|
11
|
+
export declare const OPENCODE_CONTEXT_FILE = "AGENTS.md";
|
|
12
|
+
export type ContextFile = typeof CLAUDE_CONTEXT_FILE | typeof OPENCODE_CONTEXT_FILE;
|
|
13
|
+
/** The signal the launch already carries: `WENRWA_AGENT_ENGINE` / the relay `opencode` block. Unknown = Claude. */
|
|
14
|
+
export declare function contextFileFor(engine: string | null | undefined): ContextFile;
|
|
15
|
+
/**
|
|
16
|
+
* Files to write for one engine's context: its own, plus the sibling when that
|
|
17
|
+
* already exists. One checkout can hold a Claude agent and an OpenCode agent;
|
|
18
|
+
* a file nobody has asked for is never created, but one that exists stays current.
|
|
19
|
+
*/
|
|
20
|
+
export declare function contextTargets(engine: string | null | undefined, exists: (file: ContextFile) => boolean): ContextFile[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OPENCODE_CONTEXT_FILE = exports.CLAUDE_CONTEXT_FILE = void 0;
|
|
4
|
+
exports.contextFileFor = contextFileFor;
|
|
5
|
+
exports.contextTargets = contextTargets;
|
|
6
|
+
/**
|
|
7
|
+
* oc-23 — which project-instructions file an engine reads. ONE mapping, shared by
|
|
8
|
+
* every writer that puts HiveOp context into a checkout.
|
|
9
|
+
*
|
|
10
|
+
* Claude Code reads CLAUDE.md. OpenCode reads AGENTS.md, and falls back to
|
|
11
|
+
* CLAUDE.md only while AGENTS.md is absent (oc-18 notes), so a region written
|
|
12
|
+
* to CLAUDE.md alone is invisible to an OpenCode agent the moment a crew
|
|
13
|
+
* bundle or anything else creates AGENTS.md.
|
|
14
|
+
*/
|
|
15
|
+
exports.CLAUDE_CONTEXT_FILE = 'CLAUDE.md';
|
|
16
|
+
exports.OPENCODE_CONTEXT_FILE = 'AGENTS.md';
|
|
17
|
+
/** The signal the launch already carries: `WENRWA_AGENT_ENGINE` / the relay `opencode` block. Unknown = Claude. */
|
|
18
|
+
function contextFileFor(engine) {
|
|
19
|
+
return engine === 'opencode' ? exports.OPENCODE_CONTEXT_FILE : exports.CLAUDE_CONTEXT_FILE;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Files to write for one engine's context: its own, plus the sibling when that
|
|
23
|
+
* already exists. One checkout can hold a Claude agent and an OpenCode agent;
|
|
24
|
+
* a file nobody has asked for is never created, but one that exists stays current.
|
|
25
|
+
*/
|
|
26
|
+
function contextTargets(engine, exists) {
|
|
27
|
+
const own = contextFileFor(engine);
|
|
28
|
+
const other = own === exports.CLAUDE_CONTEXT_FILE ? exports.OPENCODE_CONTEXT_FILE : exports.CLAUDE_CONTEXT_FILE;
|
|
29
|
+
return exists(other) ? [own, other] : [own];
|
|
30
|
+
}
|
|
@@ -18,8 +18,11 @@ export type ContextWriteAction = 'created' | 'replaced' | 'appended' | 'removed'
|
|
|
18
18
|
export interface ContextWriteResult {
|
|
19
19
|
/** The workspace checkout root, or null if the workspaceId was unsafe. */
|
|
20
20
|
workspaceRoot: string | null;
|
|
21
|
-
/** The CLAUDE.md
|
|
21
|
+
/** The engine's own context file (CLAUDE.md, or AGENTS.md for opencode), or null if nothing was written.
|
|
22
|
+
* Name kept for the wire contract; see `contextPaths` for every file touched. */
|
|
22
23
|
claudeMdPath: string | null;
|
|
24
|
+
/** oc-23 — every context file this write touched (the engine's own first). */
|
|
25
|
+
contextPaths?: string[];
|
|
23
26
|
/** What happened. `skipped` = unsafe workspaceId (nothing written). */
|
|
24
27
|
action: ContextWriteAction;
|
|
25
28
|
}
|
|
@@ -54,4 +57,10 @@ export declare function writeContext(input: {
|
|
|
54
57
|
* selected files or on a browser being connected at all.
|
|
55
58
|
*/
|
|
56
59
|
pointersOnly?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* oc-23 — the engine of the agent this write is for (`WENRWA_AGENT_ENGINE`).
|
|
62
|
+
* `opencode` targets AGENTS.md; anything else CLAUDE.md. A sibling file that
|
|
63
|
+
* already exists is kept in sync (see contextFile.contextTargets).
|
|
64
|
+
*/
|
|
65
|
+
engine?: string;
|
|
57
66
|
}, deps: ContextWriterDeps): ContextWriteResult;
|
|
@@ -66,6 +66,7 @@ exports.writeContext = writeContext;
|
|
|
66
66
|
* never a sibling workspace. An unsafe workspaceId writes NOTHING.
|
|
67
67
|
*/
|
|
68
68
|
const path = __importStar(require("path"));
|
|
69
|
+
const contextFile_1 = require("./contextFile");
|
|
69
70
|
const bundleInstaller_1 = require("./bundleInstaller");
|
|
70
71
|
const workGraphPointer_1 = require("./workGraphPointer");
|
|
71
72
|
const memoryPointer_1 = require("./memoryPointer");
|
|
@@ -119,13 +120,32 @@ function writeContext(input, deps) {
|
|
|
119
120
|
return { workspaceRoot: null, claudeMdPath: null, action: 'skipped' };
|
|
120
121
|
}
|
|
121
122
|
const workspaceRoot = path.posix.resolve(deps.reposBaseDir, input.workspaceId);
|
|
122
|
-
const
|
|
123
|
-
|
|
123
|
+
const fileAt = (name) => path.posix.resolve(workspaceRoot, name);
|
|
124
|
+
const targets = (0, contextFile_1.contextTargets)(input.engine, (f) => deps.exists(fileAt(f)));
|
|
125
|
+
// Belt-and-suspenders: prove containment (the names are fixed segments, so
|
|
124
126
|
// this can only fail if isSafeWorkspaceId ever regresses).
|
|
125
|
-
if (
|
|
127
|
+
if (targets.some((f) => fileAt(f) !== workspaceRoot + '/' + f)) {
|
|
126
128
|
return { workspaceRoot: null, claudeMdPath: null, action: 'skipped' };
|
|
127
129
|
}
|
|
128
|
-
const
|
|
130
|
+
const paths = targets.map(fileAt);
|
|
131
|
+
// A file that does not exist yet is seeded from the other one, so a first
|
|
132
|
+
// AGENTS.md carries the user's own CLAUDE.md guidance instead of hiding it
|
|
133
|
+
// (OpenCode ignores CLAUDE.md once AGENTS.md exists).
|
|
134
|
+
const seed = paths.find((p) => deps.exists(p)) ?? null;
|
|
135
|
+
let primary = 'noop';
|
|
136
|
+
paths.forEach((target, i) => {
|
|
137
|
+
const action = writeContextFile(input, target, seed, workspaceRoot, deps);
|
|
138
|
+
if (i === 0)
|
|
139
|
+
primary = action;
|
|
140
|
+
});
|
|
141
|
+
return { workspaceRoot, claudeMdPath: paths[0], contextPaths: paths, action: primary };
|
|
142
|
+
}
|
|
143
|
+
function writeContextFile(input, claudeMdPath, seedPath, workspaceRoot, deps) {
|
|
144
|
+
const existing = deps.exists(claudeMdPath)
|
|
145
|
+
? deps.readFile(claudeMdPath)
|
|
146
|
+
: seedPath && deps.exists(seedPath)
|
|
147
|
+
? deps.readFile(seedPath)
|
|
148
|
+
: null;
|
|
129
149
|
// wgcp-01 — the pointer rides IN the region, ahead of the arena's pushed
|
|
130
150
|
// file content, but is decided entirely by the caller (workGraphAvailable):
|
|
131
151
|
// absent/false is byte-identical to pre-wgcp-01 behavior (the pointer never
|
|
@@ -178,7 +198,7 @@ function writeContext(input, deps) {
|
|
|
178
198
|
// ── Empty content → remove the region cleanly (or no-op). ──
|
|
179
199
|
if (isEmpty) {
|
|
180
200
|
if (!hasRegion) {
|
|
181
|
-
return
|
|
201
|
+
return 'noop';
|
|
182
202
|
}
|
|
183
203
|
// Tidy only the seam we own: drop the trailing newlines of the head and the
|
|
184
204
|
// leading newlines of the tail (the blank line inserted on append), never
|
|
@@ -189,22 +209,22 @@ function writeContext(input, deps) {
|
|
|
189
209
|
if (out.length > 0)
|
|
190
210
|
out = out.replace(/\n*$/, '\n');
|
|
191
211
|
deps.writeFile(claudeMdPath, out);
|
|
192
|
-
return
|
|
212
|
+
return 'removed';
|
|
193
213
|
}
|
|
194
214
|
const region = regionBlock(content);
|
|
195
215
|
// ── Region already present → replace in place (idempotent). ──
|
|
196
216
|
if (hasRegion) {
|
|
197
217
|
deps.writeFile(claudeMdPath, before + region + after);
|
|
198
|
-
return
|
|
218
|
+
return 'replaced';
|
|
199
219
|
}
|
|
200
220
|
deps.mkdirp?.(workspaceRoot);
|
|
201
221
|
// ── No CLAUDE.md yet → create it holding just the region. ──
|
|
202
222
|
if (existing === null) {
|
|
203
223
|
deps.writeFile(claudeMdPath, region + '\n');
|
|
204
|
-
return
|
|
224
|
+
return 'created';
|
|
205
225
|
}
|
|
206
226
|
// ── CLAUDE.md present, no region → append the region after user content. ──
|
|
207
227
|
const sep = existing.length === 0 ? '' : (existing.endsWith('\n') ? '\n' : '\n\n');
|
|
208
228
|
deps.writeFile(claudeMdPath, existing + sep + region + '\n');
|
|
209
|
-
return
|
|
229
|
+
return 'appended';
|
|
210
230
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc-18 — translate a forged Claude Code crew bundle into OpenCode's files.
|
|
3
|
+
* Pure: FileMap in, FileMap out, no I/O.
|
|
4
|
+
*
|
|
5
|
+
* Verified against opencode-ai@1.18.35 (oc-18 notes in issues/oc-18-*.md):
|
|
6
|
+
* - agents load from `.opencode/agent/<name>.md` (filename = name); they are
|
|
7
|
+
* NOT read from `.claude/agents`, so each subagent must be translated;
|
|
8
|
+
* - frontmatter: description, mode (primary|subagent|all), permission; the
|
|
9
|
+
* body is the prompt. `tools:` still works but is the legacy spelling, so
|
|
10
|
+
* we emit `permission`;
|
|
11
|
+
* - `.claude/skills/**\/SKILL.md` is read natively -> passthrough;
|
|
12
|
+
* - `AGENTS.md` is the project rules file (CLAUDE.md is only a fallback when
|
|
13
|
+
* AGENTS.md is absent) -> CLAUDE.md content moves there verbatim, so the
|
|
14
|
+
* HIVEOP fence markers survive unchanged.
|
|
15
|
+
*
|
|
16
|
+
* `model` is DROPPED on purpose. An OpenCode agent's provider list is pinned to
|
|
17
|
+
* the single gateway-whitelisted model its owner chose (ADR-0013); a subagent
|
|
18
|
+
* with no model inherits its caller's, so it bills at that model. A Claude
|
|
19
|
+
* alias like `sonnet` would name a model the gateway refuses.
|
|
20
|
+
*/
|
|
21
|
+
export type FileMap = Record<string, string>;
|
|
22
|
+
export declare const OPENCODE_AGENT_DIR = ".opencode/agent";
|
|
23
|
+
/**
|
|
24
|
+
* Claude's `tools:` is an allowlist (absent = inherit all); OpenCode's default
|
|
25
|
+
* is allow, so the allowlist becomes `deny` for each gated tool not listed.
|
|
26
|
+
* Read/Grep/Glob/LS are never gated. `mcp__*` entries and unknown names are
|
|
27
|
+
* ignored: OpenCode cannot deny "all MCP" without naming servers, and the
|
|
28
|
+
* agent's own allow-all setting governs MCP anyway.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mapClaudeToolsToPermission(tools: string | string[] | undefined): Record<string, 'deny'> | undefined;
|
|
31
|
+
export interface Frontmatter {
|
|
32
|
+
data: Record<string, string>;
|
|
33
|
+
body: string;
|
|
34
|
+
/** True when the source used CRLF line endings. */
|
|
35
|
+
crlf: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Minimal YAML-subset reader: `key: value`, quoted values, `|`/`>` blocks. */
|
|
38
|
+
export declare function parseFrontmatter(src: string): Frontmatter;
|
|
39
|
+
/** One Claude subagent file -> one OpenCode agent file. */
|
|
40
|
+
export declare function translateClaudeAgentToOpencode(src: string): string;
|
|
41
|
+
export declare function translateCrewBundleForOpencode(bundle: FileMap): FileMap;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* oc-18 — translate a forged Claude Code crew bundle into OpenCode's files.
|
|
4
|
+
* Pure: FileMap in, FileMap out, no I/O.
|
|
5
|
+
*
|
|
6
|
+
* Verified against opencode-ai@1.18.35 (oc-18 notes in issues/oc-18-*.md):
|
|
7
|
+
* - agents load from `.opencode/agent/<name>.md` (filename = name); they are
|
|
8
|
+
* NOT read from `.claude/agents`, so each subagent must be translated;
|
|
9
|
+
* - frontmatter: description, mode (primary|subagent|all), permission; the
|
|
10
|
+
* body is the prompt. `tools:` still works but is the legacy spelling, so
|
|
11
|
+
* we emit `permission`;
|
|
12
|
+
* - `.claude/skills/**\/SKILL.md` is read natively -> passthrough;
|
|
13
|
+
* - `AGENTS.md` is the project rules file (CLAUDE.md is only a fallback when
|
|
14
|
+
* AGENTS.md is absent) -> CLAUDE.md content moves there verbatim, so the
|
|
15
|
+
* HIVEOP fence markers survive unchanged.
|
|
16
|
+
*
|
|
17
|
+
* `model` is DROPPED on purpose. An OpenCode agent's provider list is pinned to
|
|
18
|
+
* the single gateway-whitelisted model its owner chose (ADR-0013); a subagent
|
|
19
|
+
* with no model inherits its caller's, so it bills at that model. A Claude
|
|
20
|
+
* alias like `sonnet` would name a model the gateway refuses.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.OPENCODE_AGENT_DIR = void 0;
|
|
24
|
+
exports.mapClaudeToolsToPermission = mapClaudeToolsToPermission;
|
|
25
|
+
exports.parseFrontmatter = parseFrontmatter;
|
|
26
|
+
exports.translateClaudeAgentToOpencode = translateClaudeAgentToOpencode;
|
|
27
|
+
exports.translateCrewBundleForOpencode = translateCrewBundleForOpencode;
|
|
28
|
+
exports.OPENCODE_AGENT_DIR = '.opencode/agent';
|
|
29
|
+
const CLAUDE_AGENTS_PREFIX = '.claude/agents/';
|
|
30
|
+
/** Claude tool name -> OpenCode permission key. Read-class tools are never denied. */
|
|
31
|
+
const TOOL_TO_PERMISSION = {
|
|
32
|
+
Write: 'edit', Edit: 'edit', MultiEdit: 'edit', NotebookEdit: 'edit',
|
|
33
|
+
Bash: 'bash',
|
|
34
|
+
WebFetch: 'webfetch',
|
|
35
|
+
WebSearch: 'websearch',
|
|
36
|
+
Task: 'task',
|
|
37
|
+
TodoWrite: 'todowrite',
|
|
38
|
+
};
|
|
39
|
+
/** Order is the emitted order (golden-tested). */
|
|
40
|
+
const GATED = ['edit', 'bash', 'webfetch', 'websearch', 'task', 'todowrite'];
|
|
41
|
+
/**
|
|
42
|
+
* Claude's `tools:` is an allowlist (absent = inherit all); OpenCode's default
|
|
43
|
+
* is allow, so the allowlist becomes `deny` for each gated tool not listed.
|
|
44
|
+
* Read/Grep/Glob/LS are never gated. `mcp__*` entries and unknown names are
|
|
45
|
+
* ignored: OpenCode cannot deny "all MCP" without naming servers, and the
|
|
46
|
+
* agent's own allow-all setting governs MCP anyway.
|
|
47
|
+
*/
|
|
48
|
+
function mapClaudeToolsToPermission(tools) {
|
|
49
|
+
const list = (Array.isArray(tools) ? tools : (tools ?? '').split(','))
|
|
50
|
+
.map((t) => t.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
if (list.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
const allowed = new Set(list.map((t) => TOOL_TO_PERMISSION[t]).filter(Boolean));
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const key of GATED)
|
|
57
|
+
if (!allowed.has(key))
|
|
58
|
+
out[key] = 'deny';
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Minimal YAML-subset reader: `key: value`, quoted values, `|`/`>` blocks. */
|
|
62
|
+
function parseFrontmatter(src) {
|
|
63
|
+
const crlf = src.includes('\r\n');
|
|
64
|
+
const text = src.replace(/\r\n/g, '\n');
|
|
65
|
+
const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(text);
|
|
66
|
+
if (!m)
|
|
67
|
+
return { data: {}, body: text, crlf };
|
|
68
|
+
const data = {};
|
|
69
|
+
const lines = m[1].split('\n');
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
|
|
72
|
+
if (!kv)
|
|
73
|
+
continue;
|
|
74
|
+
let v = kv[2].trim();
|
|
75
|
+
if (/^[|>][+-]?$/.test(v)) {
|
|
76
|
+
const block = [];
|
|
77
|
+
while (i + 1 < lines.length && /^\s+\S|^\s*$/.test(lines[i + 1]))
|
|
78
|
+
block.push(lines[++i].trim());
|
|
79
|
+
v = block.join(v.startsWith('|') ? '\n' : ' ').trim();
|
|
80
|
+
}
|
|
81
|
+
else if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
|
|
82
|
+
try {
|
|
83
|
+
v = JSON.parse(v);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
v = v.slice(1, -1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
90
|
+
v = v.slice(1, -1).replace(/''/g, "'");
|
|
91
|
+
}
|
|
92
|
+
data[kv[1]] = v;
|
|
93
|
+
}
|
|
94
|
+
return { data, body: text.slice(m[0].length), crlf };
|
|
95
|
+
}
|
|
96
|
+
/** One Claude subagent file -> one OpenCode agent file. */
|
|
97
|
+
function translateClaudeAgentToOpencode(src) {
|
|
98
|
+
const { data, body, crlf } = parseFrontmatter(src);
|
|
99
|
+
const head = ['---'];
|
|
100
|
+
if (data.description)
|
|
101
|
+
head.push(`description: ${JSON.stringify(data.description)}`);
|
|
102
|
+
head.push('mode: subagent');
|
|
103
|
+
const perm = mapClaudeToolsToPermission(data.tools);
|
|
104
|
+
if (perm) {
|
|
105
|
+
head.push('permission:');
|
|
106
|
+
for (const [k, v] of Object.entries(perm))
|
|
107
|
+
head.push(` ${k}: ${v}`);
|
|
108
|
+
}
|
|
109
|
+
head.push('---');
|
|
110
|
+
// A frontmatter-less source keeps its text; with frontmatter the body already
|
|
111
|
+
// carries its own leading newline(s), which is why the join adds just one.
|
|
112
|
+
const out = head.join('\n') + '\n' + body;
|
|
113
|
+
return crlf ? out.replace(/\n/g, '\r\n') : out;
|
|
114
|
+
}
|
|
115
|
+
function translateCrewBundleForOpencode(bundle) {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [rel, content] of Object.entries(bundle ?? {})) {
|
|
118
|
+
if (rel.startsWith(CLAUDE_AGENTS_PREFIX) && rel.endsWith('.md') && !rel.slice(CLAUDE_AGENTS_PREFIX.length).includes('/')) {
|
|
119
|
+
out[`${exports.OPENCODE_AGENT_DIR}/${rel.slice(CLAUDE_AGENTS_PREFIX.length)}`] = translateClaudeAgentToOpencode(content);
|
|
120
|
+
}
|
|
121
|
+
else if (rel === 'CLAUDE.md') {
|
|
122
|
+
out['AGENTS.md'] = content;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
out[rel] = content;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
* `No conversation found with session ID: <uuid>` (rendered by the TUI).
|
|
26
26
|
* Both arrive on the PTY, so matching the combined output catches both.
|
|
27
27
|
*
|
|
28
|
+
* Claude Code ONLY (oc-08): SessionManager never wraps an `opencode` launch
|
|
29
|
+
* here — these regexes are Claude's wording. OpenCode resumes with `-s <captured
|
|
30
|
+
* id>` and a stale id shows OpenCode's own error in the pane.
|
|
31
|
+
*
|
|
28
32
|
* Both lanes: the local bridge (loopback + crew relay on a laptop) spawns
|
|
29
33
|
* `claude` through here. The hosted container does NOT — it spawns $SHELL and
|
|
30
34
|
* infra/terminal-image/autolaunch.sh runs `claude`, so the same rules live
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const OPENCODE_PACKAGE = "opencode-ai";
|
|
2
|
+
export declare const OPENCODE_INSTALL_TIMEOUT_MS: number;
|
|
3
|
+
export interface OpencodeBinaryDeps {
|
|
4
|
+
exists: (p: string) => boolean;
|
|
5
|
+
writeFile: (p: string, content: string) => void;
|
|
6
|
+
mkdirp: (dir: string) => void;
|
|
7
|
+
rm: (p: string) => void;
|
|
8
|
+
/** Run npm with exactly this env. Resolves { code } (never rejects on non-zero). */
|
|
9
|
+
runNpm: (args: string[], env: NodeJS.ProcessEnv) => Promise<{
|
|
10
|
+
code: number;
|
|
11
|
+
stderr?: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** PATH lookup: absolute path + `--version` output of the `opencode` on PATH, or null. */
|
|
14
|
+
pathOpencode: () => {
|
|
15
|
+
path: string;
|
|
16
|
+
version: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/** `<bin> --version` output, or null if it will not run. */
|
|
19
|
+
versionOf: (bin: string) => string | null;
|
|
20
|
+
env: NodeJS.ProcessEnv;
|
|
21
|
+
platform: NodeJS.Platform;
|
|
22
|
+
}
|
|
23
|
+
export interface LaunchShape {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
env: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export type PreparedLaunch = {
|
|
29
|
+
ok: true;
|
|
30
|
+
launch: LaunchShape;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function managedPrefix(baseDir: string, version?: string): string;
|
|
36
|
+
export declare const managedBin: (prefix: string) => string;
|
|
37
|
+
/** Short lines on purpose: a narrow pane wraps long ones badly. */
|
|
38
|
+
export declare function opencodeInstallFailureLines(reason: string): string[];
|
|
39
|
+
/** POSIX sh waiter: progress now, then exec once the bridge marks the install ready (or print the failure). */
|
|
40
|
+
export declare const OPENCODE_WAIT_SCRIPT: string;
|
|
41
|
+
export declare function createOpencodeProvisioner(baseDir: string, deps: OpencodeBinaryDeps): {
|
|
42
|
+
managedBin: string;
|
|
43
|
+
/** Resolve (or start provisioning) and return the launch to spawn. Synchronous. */
|
|
44
|
+
prepare(launch: LaunchShape): PreparedLaunch;
|
|
45
|
+
/** Test seam: resolves when the in-flight install (if any) settles. */
|
|
46
|
+
settled: () => Promise<void>;
|
|
47
|
+
};
|