@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.
Files changed (63) hide show
  1. package/dist/__tests__/bridge-activity-tail.test.js +16 -0
  2. package/dist/__tests__/bridge-attach-paste-engine.test.d.ts +1 -0
  3. package/dist/__tests__/bridge-attach-paste-engine.test.js +108 -0
  4. package/dist/__tests__/bridge-bundle-installer-opencode.test.d.ts +1 -0
  5. package/dist/__tests__/bridge-bundle-installer-opencode.test.js +69 -0
  6. package/dist/__tests__/bridge-context-engine.test.d.ts +1 -0
  7. package/dist/__tests__/bridge-context-engine.test.js +112 -0
  8. package/dist/__tests__/bridge-context-manifest-frame.test.js +19 -0
  9. package/dist/__tests__/bridge-crew-translator.test.d.ts +1 -0
  10. package/dist/__tests__/bridge-crew-translator.test.js +103 -0
  11. package/dist/__tests__/bridge-install-bundle-frame.test.js +24 -0
  12. package/dist/__tests__/bridge-loopback-opencode-open.test.d.ts +1 -0
  13. package/dist/__tests__/bridge-loopback-opencode-open.test.js +51 -0
  14. package/dist/__tests__/bridge-opencode-binary.test.d.ts +1 -0
  15. package/dist/__tests__/bridge-opencode-binary.test.js +128 -0
  16. package/dist/__tests__/bridge-opencode-launch.test.d.ts +1 -0
  17. package/dist/__tests__/bridge-opencode-launch.test.js +216 -0
  18. package/dist/__tests__/bridge-relay-pucc14.test.js +28 -0
  19. package/dist/__tests__/bridge-server.test.js +32 -0
  20. package/dist/__tests__/opencode-config-parity.test.d.ts +1 -0
  21. package/dist/__tests__/opencode-config-parity.test.js +85 -0
  22. package/dist/__tests__/opencode-plugin.test.d.ts +1 -0
  23. package/dist/__tests__/opencode-plugin.test.js +197 -0
  24. package/dist/__tests__/opencode-title.test.d.ts +1 -0
  25. package/dist/__tests__/opencode-title.test.js +99 -0
  26. package/dist/__tests__/opencode-transcript-reader.test.d.ts +1 -0
  27. package/dist/__tests__/opencode-transcript-reader.test.js +156 -0
  28. package/dist/bridge/activityTail.d.ts +7 -0
  29. package/dist/bridge/activityTail.js +1 -0
  30. package/dist/bridge/agentLaunch.d.ts +28 -0
  31. package/dist/bridge/agentLaunch.js +49 -0
  32. package/dist/bridge/attachmentWriter.d.ts +16 -0
  33. package/dist/bridge/attachmentWriter.js +24 -1
  34. package/dist/bridge/bundleInstaller.d.ts +3 -0
  35. package/dist/bridge/bundleInstaller.js +22 -8
  36. package/dist/bridge/claudeTranscriptReader.d.ts +7 -1
  37. package/dist/bridge/claudeTranscriptReader.js +12 -2
  38. package/dist/bridge/contextFile.d.ts +20 -0
  39. package/dist/bridge/contextFile.js +30 -0
  40. package/dist/bridge/contextWriter.d.ts +10 -1
  41. package/dist/bridge/contextWriter.js +29 -9
  42. package/dist/bridge/crewTranslator.d.ts +41 -0
  43. package/dist/bridge/crewTranslator.js +129 -0
  44. package/dist/bridge/launchSupervisor.d.ts +4 -0
  45. package/dist/bridge/opencodeBinary.d.ts +47 -0
  46. package/dist/bridge/opencodeBinary.js +161 -0
  47. package/dist/bridge/opencodeConfig.d.ts +43 -0
  48. package/dist/bridge/opencodeConfig.js +109 -0
  49. package/dist/bridge/opencodePlugin.d.ts +13 -0
  50. package/dist/bridge/opencodePlugin.js +54 -0
  51. package/dist/bridge/opencodeTranscriptReader.d.ts +24 -0
  52. package/dist/bridge/opencodeTranscriptReader.js +178 -0
  53. package/dist/bridge/relayClient.d.ts +34 -1
  54. package/dist/bridge/relayClient.js +33 -8
  55. package/dist/bridge/screenshotAttach.d.ts +2 -0
  56. package/dist/bridge/screenshotAttach.js +3 -1
  57. package/dist/bridge/server.d.ts +15 -0
  58. package/dist/bridge/server.js +21 -3
  59. package/dist/bridge/sessionManager.d.ts +5 -0
  60. package/dist/bridge/sessionManager.js +21 -1
  61. package/dist/commands/bridge.js +76 -9
  62. package/hooks/opencode-plugin.mjs +186 -0
  63. package/package.json +1 -1
@@ -85,6 +85,22 @@ describe('startActivityTail', () => {
85
85
  expect(records).toEqual([{ sessionId: 's1', filePaths: ['src/foo.ts'], tool: 'Edit' }]);
86
86
  handle.close();
87
87
  });
88
+ it('oc-22: carries kind needs_you through; every other kind stays off the record (Claude records unchanged)', () => {
89
+ const file = tmpFile();
90
+ fs.writeFileSync(file, '');
91
+ const { scheduleInterval, cancelInterval, tick } = fakeInterval();
92
+ const records = [];
93
+ (0, activityTail_1.startActivityTail)({ file, onRecord: (r) => records.push(r), scheduleInterval, cancelInterval });
94
+ fs.appendFileSync(file, JSON.stringify({ sessionId: 's1', kind: 'needs_you', thinking: 'Waiting for you' }) + '\n');
95
+ fs.appendFileSync(file, JSON.stringify({ sessionId: 's1', kind: 'stop', thinking: '' }) + '\n');
96
+ fs.appendFileSync(file, JSON.stringify({ sessionId: 's1', kind: 'tool', thinking: 'Reading a.ts' }) + '\n');
97
+ tick();
98
+ expect(records).toEqual([
99
+ { sessionId: 's1', kind: 'needs_you', thinking: 'Waiting for you' },
100
+ { sessionId: 's1', thinking: '' },
101
+ { sessionId: 's1', thinking: 'Reading a.ts' },
102
+ ]);
103
+ });
88
104
  it('does NOT replay bytes already in the file before the tail started', () => {
89
105
  const file = tmpFile();
90
106
  fs.writeFileSync(file, JSON.stringify({ sessionId: 'pre-existing', thinking: 'old' }) + '\n');
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ /**
37
+ * oc-16 — image paste parity. After `attach-file` writes the image, an
38
+ * OpenCode session gets the path as a BRACKETED PASTE with no Enter (its TUI
39
+ * turns a pasted image path into an `[Image N]` attachment); a Claude Code
40
+ * session is byte-identical to before (nothing reaches the PTY from the
41
+ * bridge). Same shared handler on both lanes.
42
+ */
43
+ const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
46
+ const sessionManager_1 = require("../bridge/sessionManager");
47
+ const attachmentWriter_1 = require("../bridge/attachmentWriter");
48
+ class FakePty {
49
+ written = [];
50
+ write(d) { this.written.push(d); }
51
+ resize() { }
52
+ kill() { }
53
+ onData() { }
54
+ onExit() { }
55
+ }
56
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8cfc0f01f0005000201a5f3b4d60000000049454e44ae426082', 'hex');
57
+ let tmp;
58
+ let ptys;
59
+ let sessions;
60
+ beforeEach(() => {
61
+ tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'attach-paste-')));
62
+ ptys = [];
63
+ sessions = new sessionManager_1.SessionManager({ spawn: () => { const p = new FakePty(); ptys.push(p); return p; } });
64
+ });
65
+ afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
66
+ const frame = (sessionId) => ({ rid: 'r', sessionId, name: 'a.png', data: PNG.toString('base64') });
67
+ describe('pastePayloadFor', () => {
68
+ it('opencode: exact bracketed paste, no Enter', () => {
69
+ expect((0, attachmentWriter_1.pastePayloadFor)('opencode', '/w/.hiveop/attachments/x.png')).toBe('\x1b[200~/w/.hiveop/attachments/x.png\x1b[201~');
70
+ });
71
+ it('opencode: spaces in the path are backslash-escaped', () => {
72
+ expect((0, attachmentWriter_1.pastePayloadFor)('opencode', '/My Repo/a.png')).toBe('\x1b[200~/My\\ Repo/a.png\x1b[201~');
73
+ });
74
+ it('claude / unknown: nothing', () => {
75
+ expect((0, attachmentWriter_1.pastePayloadFor)('claude', '/w/x.png')).toBeNull();
76
+ expect((0, attachmentWriter_1.pastePayloadFor)(undefined, '/w/x.png')).toBeNull();
77
+ });
78
+ });
79
+ describe('handleAttachFrame engine dispatch', () => {
80
+ it('opencode session: file in the same place, paste written once, no \\r', () => {
81
+ sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'oc', command: 'opencode' });
82
+ const reply = (0, attachmentWriter_1.handleAttachFrame)(frame('oc'), sessions);
83
+ expect(reply.ok).toBe(true);
84
+ expect(reply.path.startsWith(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR) + path.sep)).toBe(true);
85
+ expect(fs.readFileSync(reply.path).equals(PNG)).toBe(true);
86
+ expect(ptys[0].written).toEqual([`\x1b[200~${reply.path}\x1b[201~`]);
87
+ expect(ptys[0].written.join('')).not.toMatch(/[\r\n]/);
88
+ expect(reply.pasted).toBe(true);
89
+ });
90
+ it('claude session: nothing written to the PTY, reply unchanged', () => {
91
+ sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'cc', command: 'claude' });
92
+ const reply = (0, attachmentWriter_1.handleAttachFrame)(frame('cc'), sessions);
93
+ expect(reply.ok).toBe(true);
94
+ expect(reply).not.toHaveProperty('pasted');
95
+ expect(ptys[0].written).toEqual([]);
96
+ });
97
+ it('bare shell session: nothing written', () => {
98
+ sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'sh' });
99
+ (0, attachmentWriter_1.handleAttachFrame)(frame('sh'), sessions);
100
+ expect(ptys[0].written).toEqual([]);
101
+ });
102
+ it('failed write on an opencode session pastes nothing', () => {
103
+ sessions.open({ cols: 80, rows: 24, cwd: tmp, id: 'oc', command: 'opencode' });
104
+ const reply = (0, attachmentWriter_1.handleAttachFrame)({ ...frame('oc'), data: Buffer.from('hello').toString('base64') }, sessions);
105
+ expect(reply.ok).toBe(false);
106
+ expect(ptys[0].written).toEqual([]);
107
+ });
108
+ });
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /** oc-18 — engine dispatch at the single bundle write site (both lanes use installBundle). */
4
+ const bundleInstaller_1 = require("../bridge/bundleInstaller");
5
+ const MARK = '## Prompt Defense Baseline';
6
+ const agent = (d) => `---\nname: ${d}\ndescription: ${d}\ntools: Read\nmodel: sonnet\n---\nbody\n${MARK}\n`;
7
+ const makeFs = (seed = {}) => {
8
+ const files = new Map(Object.entries(seed));
9
+ return {
10
+ files,
11
+ deps: {
12
+ reposBaseDir: '/data/repos',
13
+ mkdirp: () => { },
14
+ writeFile: (p, c) => { files.set(p, c); },
15
+ listDir: (d) => [...files.keys()].filter((k) => k.startsWith(d + '/')).map((k) => k.slice(d.length + 1)),
16
+ readFile: (p) => files.get(p) ?? '',
17
+ remove: (p) => { files.delete(p); },
18
+ },
19
+ };
20
+ };
21
+ const BUNDLE = {
22
+ 'CLAUDE.md': '# c\n',
23
+ '.claude/agents/a.md': agent('a'),
24
+ '.claude/skills/s/SKILL.md': 'skill',
25
+ };
26
+ describe('installBundle engine dispatch', () => {
27
+ it('no engine: Claude files, byte-identical to before', () => {
28
+ const { files, deps } = makeFs();
29
+ const r = (0, bundleInstaller_1.installBundle)({ workspaceId: 'w', files: BUNDLE }, deps);
30
+ expect(r.written.sort()).toEqual(['.claude/agents/a.md', '.claude/skills/s/SKILL.md', 'CLAUDE.md']);
31
+ expect(files.get('/data/repos/w/.claude/agents/a.md')).toBe(BUNDLE['.claude/agents/a.md']);
32
+ expect(files.has('/data/repos/w/AGENTS.md')).toBe(false);
33
+ });
34
+ it('engine=claude behaves like no engine', () => {
35
+ const { deps } = makeFs();
36
+ expect((0, bundleInstaller_1.installBundle)({ workspaceId: 'w', engine: 'claude', files: BUNDLE }, deps).written).toContain('CLAUDE.md');
37
+ });
38
+ it('engine=opencode writes translated OpenCode files, not .claude/agents or CLAUDE.md', () => {
39
+ const { files, deps } = makeFs();
40
+ const r = (0, bundleInstaller_1.installBundle)({ workspaceId: 'w', engine: 'opencode', files: BUNDLE }, deps);
41
+ expect(r.written.sort()).toEqual(['.claude/skills/s/SKILL.md', '.opencode/agent/a.md', 'AGENTS.md']);
42
+ expect(files.get('/data/repos/w/.opencode/agent/a.md')).toContain('mode: subagent');
43
+ expect(files.get('/data/repos/w/.opencode/agent/a.md')).not.toMatch(/^model:/m);
44
+ expect(files.get('/data/repos/w/AGENTS.md')).toBe('# c\n');
45
+ expect(r.skipped).toEqual([]);
46
+ });
47
+ it('engine=opencode still refuses unsafe and unmanaged paths', () => {
48
+ const { deps } = makeFs();
49
+ const r = (0, bundleInstaller_1.installBundle)({ workspaceId: 'w', engine: 'opencode', files: { '../x': 'a', 'package.json': 'b', '.opencode/agent/../../evil': 'c' } }, deps);
50
+ expect(r.written).toEqual([]);
51
+ expect(r.skipped.length).toBe(3);
52
+ });
53
+ it('engine=opencode crew swap prunes stale marker-carrying .opencode/agent files, keeps hand-written ones', () => {
54
+ const { files, deps } = makeFs({
55
+ '/data/repos/w/.opencode/agent/old.md': `x\n${MARK}\n`,
56
+ '/data/repos/w/.opencode/agent/mine.md': 'hand written',
57
+ });
58
+ const r = (0, bundleInstaller_1.installBundle)({ workspaceId: 'w', engine: 'opencode', files: BUNDLE }, deps);
59
+ expect(r.pruned).toEqual(['.opencode/agent/old.md']);
60
+ expect(files.has('/data/repos/w/.opencode/agent/mine.md')).toBe(true);
61
+ expect(files.has('/data/repos/w/.opencode/agent/a.md')).toBe(true);
62
+ });
63
+ it('local lane (explicit workspaceRoot) dispatches the same way', () => {
64
+ const { files, deps } = makeFs();
65
+ (0, bundleInstaller_1.installBundle)({ workspaceId: 'ignored/..', engine: 'opencode', files: BUNDLE }, { ...deps, reposBaseDir: '', workspaceRoot: '/Users/me/app' });
66
+ expect(files.has('/Users/me/app/AGENTS.md')).toBe(true);
67
+ expect(files.has('/Users/me/app/.opencode/agent/a.md')).toBe(true);
68
+ });
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * oc-23 — the context writers are engine-aware. Claude Code reads CLAUDE.md; OpenCode reads
5
+ * AGENTS.md and ignores CLAUDE.md the moment AGENTS.md exists (oc-18 notes).
6
+ *
7
+ * hosted: contextWriter writes the engine's own file, and keeps a sibling that already
8
+ * exists in sync (one checkout can hold a Claude agent and an OpenCode agent).
9
+ * local : the bridge only ever writes `.hiveop/context.md` (ADR-0035 file-write boundary);
10
+ * Claude loads it via a SessionStart hook, OpenCode via its `instructions` config.
11
+ */
12
+ const contextFile_1 = require("../bridge/contextFile");
13
+ const contextWriter_1 = require("../bridge/contextWriter");
14
+ const opencodeConfig_1 = require("../bridge/opencodeConfig");
15
+ const agentLaunch_1 = require("../bridge/agentLaunch");
16
+ const BASE = '/data/repos';
17
+ const CLAUDE = '/data/repos/ws-1/CLAUDE.md';
18
+ const AGENTS = '/data/repos/ws-1/AGENTS.md';
19
+ const region = (body) => `${contextWriter_1.CONTEXT_START_MARKER}\n${body}\n${contextWriter_1.CONTEXT_END_MARKER}`;
20
+ const makeFs = (seed = {}) => {
21
+ const files = new Map(Object.entries(seed));
22
+ return {
23
+ files,
24
+ deps: {
25
+ reposBaseDir: BASE,
26
+ exists: (p) => files.has(p),
27
+ readFile: (p) => files.get(p) ?? '',
28
+ writeFile: (p, c) => { files.set(p, c); },
29
+ mkdirp: () => { },
30
+ },
31
+ };
32
+ };
33
+ describe('contextFileFor', () => {
34
+ it('maps the engine to the file that engine reads', () => {
35
+ expect((0, contextFile_1.contextFileFor)('opencode')).toBe('AGENTS.md');
36
+ expect((0, contextFile_1.contextFileFor)('claude_code')).toBe('CLAUDE.md');
37
+ expect((0, contextFile_1.contextFileFor)(undefined)).toBe('CLAUDE.md');
38
+ expect((0, contextFile_1.contextFileFor)('something-new')).toBe('CLAUDE.md');
39
+ });
40
+ it('targets: own file first, plus the sibling only when it already exists', () => {
41
+ const none = () => false;
42
+ expect((0, contextFile_1.contextTargets)('opencode', none)).toEqual(['AGENTS.md']);
43
+ expect((0, contextFile_1.contextTargets)('claude_code', none)).toEqual(['CLAUDE.md']);
44
+ expect((0, contextFile_1.contextTargets)('opencode', (f) => f === 'CLAUDE.md')).toEqual(['AGENTS.md', 'CLAUDE.md']);
45
+ expect((0, contextFile_1.contextTargets)(undefined, (f) => f === 'AGENTS.md')).toEqual(['CLAUDE.md', 'AGENTS.md']);
46
+ });
47
+ });
48
+ describe('hosted writeContext by engine', () => {
49
+ it('claude (or no engine): CLAUDE.md only, no AGENTS.md created', () => {
50
+ const { files, deps } = makeFs();
51
+ const r = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'ctx' }, deps);
52
+ expect(r.action).toBe('created');
53
+ expect(files.get(CLAUDE)).toBe(region('ctx') + '\n');
54
+ expect(files.has(AGENTS)).toBe(false);
55
+ });
56
+ it('opencode: writes AGENTS.md and does not create CLAUDE.md', () => {
57
+ const { files, deps } = makeFs();
58
+ const r = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'ctx', engine: 'opencode' }, deps);
59
+ expect(r.action).toBe('created');
60
+ expect(r.contextPaths).toEqual([AGENTS]);
61
+ expect(files.get(AGENTS)).toBe(region('ctx') + '\n');
62
+ expect(files.has(CLAUDE)).toBe(false);
63
+ });
64
+ it('opencode: a first AGENTS.md is seeded from the user CLAUDE.md so its guidance is not hidden', () => {
65
+ const user = '# Mine\n\nKeep tests green.\n';
66
+ const { files, deps } = makeFs({ [CLAUDE]: user });
67
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'ctx', engine: 'opencode' }, deps);
68
+ expect(files.get(AGENTS).startsWith(user)).toBe(true);
69
+ expect(files.get(AGENTS)).toContain(region('ctx'));
70
+ // the sibling that already existed stays in sync
71
+ expect(files.get(CLAUDE)).toContain(region('ctx'));
72
+ expect(files.get(CLAUDE).startsWith(user)).toBe(true);
73
+ });
74
+ it('mixed checkout: each engine write keeps the other file current, one region each', () => {
75
+ const { files, deps } = makeFs();
76
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'v1' }, deps);
77
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'v1', engine: 'opencode' }, deps);
78
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'v2' }, deps); // a later claude manifest
79
+ for (const p of [CLAUDE, AGENTS]) {
80
+ expect(files.get(p)).toContain(region('v2'));
81
+ expect(files.get(p).split(contextWriter_1.CONTEXT_START_MARKER).length - 1).toBe(1);
82
+ }
83
+ });
84
+ it('pointers-only refresh by opencode keeps pushed content in AGENTS.md', () => {
85
+ const { files, deps } = makeFs();
86
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'pushed', engine: 'opencode' }, deps);
87
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', pointersOnly: true, workGraphAvailable: true, engine: 'opencode' }, deps);
88
+ expect(files.get(AGENTS)).toContain('pushed');
89
+ });
90
+ it('empty content removes the region from every file it touches', () => {
91
+ const { files, deps } = makeFs();
92
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'x' }, deps);
93
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: 'x', engine: 'opencode' }, deps);
94
+ (0, contextWriter_1.writeContext)({ workspaceId: 'ws-1', content: '', engine: 'opencode' }, deps);
95
+ expect(files.get(AGENTS) ?? '').not.toContain(contextWriter_1.CONTEXT_START_MARKER);
96
+ expect(files.get(CLAUDE) ?? '').not.toContain(contextWriter_1.CONTEXT_START_MARKER);
97
+ });
98
+ });
99
+ describe('local OpenCode reads .hiveop/context.md through its instructions config', () => {
100
+ it('canonical config: instructions only when asked', () => {
101
+ const base = { model: 'm', gatewayBaseUrl: 'https://g/v1' };
102
+ expect((0, opencodeConfig_1.buildOpencodeConfig)(base).instructions).toBeUndefined();
103
+ expect((0, opencodeConfig_1.buildOpencodeConfig)({ ...base, instructions: ['.hiveop/context.md'] }).instructions).toEqual(['.hiveop/context.md']);
104
+ });
105
+ it('a local opencode launch loads the bridge context file; the Claude launch is unchanged', () => {
106
+ const l = (0, agentLaunch_1.resolveAgentLaunch)({
107
+ opencode: { engine: 'opencode', model: 'm', gatewayUrl: 'https://g/v1', gatewayToken: 't' },
108
+ });
109
+ expect(JSON.parse(l.env.OPENCODE_CONFIG_CONTENT).instructions).toEqual(['.hiveop/context.md']);
110
+ expect((0, agentLaunch_1.resolveAgentLaunch)({}).env.OPENCODE_CONFIG_CONTENT).toBeUndefined();
111
+ });
112
+ });
@@ -65,6 +65,25 @@ function closeRelay(wss) {
65
65
  });
66
66
  }
67
67
  describe('relay client — context-manifest frame (pucc-int-01)', () => {
68
+ it('oc-23: passes the frame engine to the writer, and omits it when absent', async () => {
69
+ const relay = await startFakeRelay();
70
+ const sessions = new sessionManager_1.SessionManager(makeFactory());
71
+ const calls = [];
72
+ const handle = (0, relayClient_1.startRelayClient)({
73
+ relayUrl: `ws://127.0.0.1:${relay.port}`,
74
+ relayToken: 'tok',
75
+ sessions,
76
+ writeContext: (m) => { calls.push(m); return { action: 'created', claudeMdPath: null }; },
77
+ });
78
+ const ws = await relay.nextConnection();
79
+ const q = frameQueue(ws);
80
+ await q.next(); // auth
81
+ ws.send(JSON.stringify({ t: 'context-manifest', workspaceId: 'ws-7', content: 'a', engine: 'opencode' }));
82
+ await q.next();
83
+ expect(calls).toEqual([{ workspaceId: 'ws-7', content: 'a', engine: 'opencode' }]);
84
+ handle.close();
85
+ await closeRelay(relay.wss);
86
+ });
68
87
  it('routes a context-manifest frame to the injected writer and acks context-written', async () => {
69
88
  const relay = await startFakeRelay();
70
89
  const sessions = new sessionManager_1.SessionManager(makeFactory());
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * oc-18 — Claude Code crew bundle -> OpenCode files.
5
+ *
6
+ * Formats verified against opencode-ai@1.18.35 (`opencode agent list`,
7
+ * `opencode debug agent <n>`, `opencode debug skill`): agents load from
8
+ * `.opencode/agent/<name>.md` (NOT from `.claude/agents`), `permission` is the
9
+ * tool gate, `.claude/skills/**` loads natively, `AGENTS.md` is the rules file.
10
+ */
11
+ const crewTranslator_1 = require("../bridge/crewTranslator");
12
+ const MARK = '## Prompt Defense Baseline';
13
+ const FENCE = '<!-- HIVEOP:CONTEXT:START -->\nfiles\n<!-- HIVEOP:CONTEXT:END -->';
14
+ const FIXTURE = {
15
+ 'CLAUDE.md': `# Crew\n\n${FENCE}\n`,
16
+ '.claude/agents/reviewer.md': `---\nname: reviewer\ndescription: "Reviews diffs: strict"\ntools: Read, Grep, Glob\nmodel: sonnet\ncolor: blue\n---\n\nYou review.\n\n${MARK}\nBe safe.\n`,
17
+ '.claude/agents/builder.md': `---\nname: builder\ndescription: Builds things\n---\nYou build.\n${MARK}\n`,
18
+ '.claude/skills/run-crew/SKILL.md': '---\nname: run-crew\ndescription: x\n---\nrun\n',
19
+ '.claude/handoff/PROTOCOL.md': 'blackboard',
20
+ };
21
+ describe('mapClaudeToolsToPermission', () => {
22
+ it('absent or empty tools inherit everything (no permission block)', () => {
23
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)(undefined)).toBeUndefined();
24
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)('')).toBeUndefined();
25
+ });
26
+ it('is an allowlist: unlisted known tools are denied', () => {
27
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)('Read, Grep, Glob')).toEqual({
28
+ edit: 'deny', bash: 'deny', webfetch: 'deny', websearch: 'deny', task: 'deny', todowrite: 'deny',
29
+ });
30
+ });
31
+ it('folds Write/Edit/MultiEdit/NotebookEdit into edit and keeps bash when listed', () => {
32
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)('Write, Bash, WebFetch')).toEqual({
33
+ websearch: 'deny', task: 'deny', todowrite: 'deny',
34
+ });
35
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)('MultiEdit')).toMatchObject({ bash: 'deny' });
36
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)('MultiEdit')).not.toHaveProperty('edit');
37
+ });
38
+ it('accepts an array and ignores mcp__ entries and unknown names', () => {
39
+ expect((0, crewTranslator_1.mapClaudeToolsToPermission)(['Read', 'mcp__x__y', 'Bogus'])).toMatchObject({ edit: 'deny', bash: 'deny' });
40
+ });
41
+ });
42
+ describe('translateClaudeAgentToOpencode', () => {
43
+ it('emits mode subagent, quoted description, permission, body; drops model/name/color/tools', () => {
44
+ const out = (0, crewTranslator_1.translateClaudeAgentToOpencode)(FIXTURE['.claude/agents/reviewer.md']);
45
+ const { data, body } = (0, crewTranslator_1.parseFrontmatter)(out);
46
+ expect(data.mode).toBe('subagent');
47
+ expect(data.description).toBe('Reviews diffs: strict');
48
+ expect(data).not.toHaveProperty('model');
49
+ expect(data).not.toHaveProperty('tools');
50
+ expect(data).not.toHaveProperty('color');
51
+ expect(data).not.toHaveProperty('name');
52
+ expect(out).toContain('permission:\n edit: deny');
53
+ expect(body).toContain(MARK);
54
+ });
55
+ it('preserves CRLF', () => {
56
+ const crlf = '---\r\ndescription: d\r\ntools: Read\r\n---\r\nbody\r\n';
57
+ const out = (0, crewTranslator_1.translateClaudeAgentToOpencode)(crlf);
58
+ expect(out).toContain('\r\n');
59
+ expect(out.replace(/\r\n/g, '')).not.toContain('\n');
60
+ });
61
+ it('a file with no frontmatter still becomes a valid subagent', () => {
62
+ const out = (0, crewTranslator_1.translateClaudeAgentToOpencode)('just a prompt');
63
+ expect((0, crewTranslator_1.parseFrontmatter)(out).data.mode).toBe('subagent');
64
+ expect(out).toContain('just a prompt');
65
+ });
66
+ });
67
+ describe('translateCrewBundleForOpencode (golden)', () => {
68
+ const out = (0, crewTranslator_1.translateCrewBundleForOpencode)(FIXTURE);
69
+ it('maps paths', () => {
70
+ expect(Object.keys(out).sort()).toEqual([
71
+ '.claude/handoff/PROTOCOL.md',
72
+ '.claude/skills/run-crew/SKILL.md',
73
+ '.opencode/agent/builder.md',
74
+ '.opencode/agent/reviewer.md',
75
+ 'AGENTS.md',
76
+ ]);
77
+ });
78
+ it('reviewer golden', () => {
79
+ expect(out['.opencode/agent/reviewer.md']).toBe(`---\ndescription: "Reviews diffs: strict"\nmode: subagent\npermission:\n edit: deny\n bash: deny\n webfetch: deny\n websearch: deny\n task: deny\n todowrite: deny\n---\n\nYou review.\n\n${MARK}\nBe safe.\n`);
80
+ });
81
+ it('builder (no tools) has no permission block', () => {
82
+ expect(out['.opencode/agent/builder.md']).toBe(`---\ndescription: "Builds things"\nmode: subagent\n---\nYou build.\n${MARK}\n`);
83
+ });
84
+ it('skills and handoff pass through byte-identical', () => {
85
+ expect(out['.claude/skills/run-crew/SKILL.md']).toBe(FIXTURE['.claude/skills/run-crew/SKILL.md']);
86
+ expect(out['.claude/handoff/PROTOCOL.md']).toBe('blackboard');
87
+ });
88
+ it('CLAUDE.md fenced region round-trips into AGENTS.md with the same markers, byte for byte', () => {
89
+ expect(out['AGENTS.md']).toBe(FIXTURE['CLAUDE.md']);
90
+ expect(out['AGENTS.md']).toContain('<!-- HIVEOP:CONTEXT:START -->');
91
+ expect(out['AGENTS.md']).toContain('<!-- HIVEOP:CONTEXT:END -->');
92
+ });
93
+ it('is pure: input untouched, same output twice', () => {
94
+ const copy = JSON.stringify(FIXTURE);
95
+ expect((0, crewTranslator_1.translateCrewBundleForOpencode)(FIXTURE)).toEqual(out);
96
+ expect(JSON.stringify(FIXTURE)).toBe(copy);
97
+ });
98
+ it('never emits a model field (billing is locked to the agent model via the gateway whitelist)', () => {
99
+ for (const [p, c] of Object.entries(out))
100
+ if (p.startsWith('.opencode/'))
101
+ expect(c).not.toMatch(/^model:/m);
102
+ });
103
+ });
@@ -92,6 +92,30 @@ describe('relay client — install-bundle frame (pucc-int-04)', () => {
92
92
  handle.close();
93
93
  await closeRelay(relay.wss);
94
94
  });
95
+ it('oc-18: passes the frame engine through to the installer (and omits it when absent)', async () => {
96
+ const relay = await startFakeRelay();
97
+ const sessions = new sessionManager_1.SessionManager(makeFactory());
98
+ const calls = [];
99
+ const handle = (0, relayClient_1.startRelayClient)({
100
+ relayUrl: `ws://127.0.0.1:${relay.port}`,
101
+ relayToken: 'tok',
102
+ sessions,
103
+ installBundle: (bundle) => { calls.push(bundle); return { written: [], skipped: [] }; },
104
+ });
105
+ const ws = await relay.nextConnection();
106
+ const q = frameQueue(ws);
107
+ await q.next(); // auth
108
+ ws.send(JSON.stringify({ t: 'install-bundle', workspaceId: 'ws-7', files: {}, engine: 'opencode' }));
109
+ await q.next();
110
+ ws.send(JSON.stringify({ t: 'install-bundle', workspaceId: 'ws-7', files: {} }));
111
+ await q.next();
112
+ expect(calls).toEqual([
113
+ { workspaceId: 'ws-7', files: {}, engine: 'opencode' },
114
+ { workspaceId: 'ws-7', files: {} },
115
+ ]);
116
+ handle.close();
117
+ await closeRelay(relay.wss);
118
+ });
95
119
  it('errors (never crashes) when no installer is wired', async () => {
96
120
  const relay = await startFakeRelay();
97
121
  const sessions = new sessionManager_1.SessionManager(makeFactory());
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * oc-20 — a local pane opened from the browser over the loopback bridge launches
8
+ * OpenCode when the `open` frame carries the server-delivered block, and stays
9
+ * Claude Code when it does not.
10
+ */
11
+ const ws_1 = __importDefault(require("ws"));
12
+ const server_1 = require("../bridge/server");
13
+ const sessionManager_1 = require("../bridge/sessionManager");
14
+ const ORIGIN = 'https://app.hiveop.io';
15
+ const TOKEN = 'tok';
16
+ const block = { engine: 'opencode', model: 'moonshotai/kimi-k2', gatewayUrl: 'https://api.example.test/api/v1/opencode/v1', gatewayToken: 'hgw_abc' };
17
+ describe('loopback open (browser -> bridge)', () => {
18
+ let handle;
19
+ let spawns;
20
+ beforeEach(async () => {
21
+ spawns = [];
22
+ const pty = { write() { }, resize() { }, kill() { }, onData() { }, onExit() { } };
23
+ handle = await (0, server_1.startBridgeServer)({
24
+ sessionToken: TOKEN, allowedOrigin: ORIGIN,
25
+ sessions: new sessionManager_1.SessionManager({ spawn: (o) => { spawns.push(o); return pty; } }),
26
+ resolveLocalAgentCwd: () => '/tmp/proj',
27
+ commandExists: () => true,
28
+ });
29
+ });
30
+ afterEach(async () => { await handle.close(); });
31
+ const open = async (extra) => {
32
+ const ws = new ws_1.default(`ws://127.0.0.1:${handle.port}`, { headers: { origin: ORIGIN } });
33
+ await new Promise((r) => ws.on('open', r));
34
+ ws.send(JSON.stringify({ v: 1, token: TOKEN }));
35
+ const opened = new Promise((r) => ws.on('message', (m) => { if (JSON.parse(String(m)).t === 'opened')
36
+ r(null); }));
37
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, agentId: 'a1', workspaceId: 'w1', ...extra }));
38
+ await opened;
39
+ ws.close();
40
+ };
41
+ it('spawns opencode with the gateway env when the frame carries the block', async () => {
42
+ await open({ opencode: block });
43
+ expect(spawns[0].command).toBe('opencode');
44
+ expect(spawns[0].env).toMatchObject({ HIVEOP_GATEWAY_TOKEN: 'hgw_abc' });
45
+ });
46
+ it('spawns claude when there is no block (Claude Code panes unchanged)', async () => {
47
+ await open({});
48
+ expect(spawns[0].command).toBe('claude');
49
+ expect(spawns[0].env?.HIVEOP_GATEWAY_TOKEN).toBeUndefined();
50
+ });
51
+ });
@@ -0,0 +1 @@
1
+ export {};