@wenrwa/marketplace-cli 0.7.7 → 0.8.1

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 (61) 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-attach-screenshot.test.d.ts +1 -0
  5. package/dist/__tests__/bridge-attach-screenshot.test.js +250 -0
  6. package/dist/__tests__/bridge-bundle-installer-opencode.test.d.ts +1 -0
  7. package/dist/__tests__/bridge-bundle-installer-opencode.test.js +69 -0
  8. package/dist/__tests__/bridge-context-engine.test.d.ts +1 -0
  9. package/dist/__tests__/bridge-context-engine.test.js +112 -0
  10. package/dist/__tests__/bridge-context-manifest-frame.test.js +19 -0
  11. package/dist/__tests__/bridge-crew-translator.test.d.ts +1 -0
  12. package/dist/__tests__/bridge-crew-translator.test.js +103 -0
  13. package/dist/__tests__/bridge-install-bundle-frame.test.js +24 -0
  14. package/dist/__tests__/bridge-loopback-opencode-open.test.d.ts +1 -0
  15. package/dist/__tests__/bridge-loopback-opencode-open.test.js +51 -0
  16. package/dist/__tests__/bridge-opencode-launch.test.d.ts +1 -0
  17. package/dist/__tests__/bridge-opencode-launch.test.js +179 -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/opencodeConfig.d.ts +41 -0
  46. package/dist/bridge/opencodeConfig.js +107 -0
  47. package/dist/bridge/opencodePlugin.d.ts +13 -0
  48. package/dist/bridge/opencodePlugin.js +54 -0
  49. package/dist/bridge/opencodeTranscriptReader.d.ts +24 -0
  50. package/dist/bridge/opencodeTranscriptReader.js +178 -0
  51. package/dist/bridge/relayClient.d.ts +27 -1
  52. package/dist/bridge/relayClient.js +24 -7
  53. package/dist/bridge/screenshotAttach.d.ts +35 -0
  54. package/dist/bridge/screenshotAttach.js +173 -0
  55. package/dist/bridge/server.d.ts +9 -0
  56. package/dist/bridge/server.js +19 -1
  57. package/dist/bridge/sessionManager.d.ts +5 -0
  58. package/dist/bridge/sessionManager.js +21 -1
  59. package/dist/commands/bridge.js +36 -9
  60. package/hooks/opencode-plugin.mjs +186 -0
  61. 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 @@
1
+ export {};
@@ -0,0 +1,250 @@
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
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ /**
40
+ * Screenshot drop — `attach-screenshot {rid, sessionId, name}` (local bridge
41
+ * only). The browser cannot read a dropped macOS screenshot thumbnail, so the
42
+ * bridge reads `<screenshot folder>/<name>` once macOS has written it.
43
+ *
44
+ * Fake fs + fake clock: the folder is a Map, sleep advances the clock, so the
45
+ * ~6s poll runs instantly and deterministically. The attachment WRITE is real
46
+ * (temp dir), so the bytes on disk are compared with the bytes "dropped".
47
+ */
48
+ const ws_1 = __importDefault(require("ws"));
49
+ const fs = __importStar(require("fs"));
50
+ const os = __importStar(require("os"));
51
+ const path = __importStar(require("path"));
52
+ const server_1 = require("../bridge/server");
53
+ const relayClient_1 = require("../bridge/relayClient");
54
+ const sessionManager_1 = require("../bridge/sessionManager");
55
+ const attachmentWriter_1 = require("../bridge/attachmentWriter");
56
+ const screenshotAttach_1 = require("../bridge/screenshotAttach");
57
+ class FakePty {
58
+ written = [];
59
+ write(d) { this.written.push(d); }
60
+ resize() { }
61
+ kill() { }
62
+ onData() { }
63
+ onExit() { }
64
+ }
65
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8cfc0f01f0005000201a5f3b4d60000000049454e44ae426082', 'hex');
66
+ const HOME = '/Users/me';
67
+ const NAME = 'Screenshot 2026-10-04 at 9.41.12 AM.png';
68
+ /** A fake screenshot folder: files appear at a given fake time. */
69
+ function fakeDeps(opts) {
70
+ let clock = 0;
71
+ const reads = [];
72
+ const deps = {
73
+ platform: opts.platform ?? 'darwin',
74
+ homedir: () => HOME,
75
+ readScreencaptureLocation: async () => (opts.location === undefined ? null : opts.location),
76
+ stat: (p) => {
77
+ const f = opts.files?.[p];
78
+ if (!f || clock < (f.at ?? 0))
79
+ return null;
80
+ return { isFile: f.isFile ?? true, size: f.data.length };
81
+ },
82
+ readFile: (p) => { reads.push(p); return opts.files[p].data; },
83
+ sleep: async (ms) => { clock += ms; },
84
+ now: () => clock,
85
+ attachment: attachmentWriter_1.realAttachmentDeps,
86
+ };
87
+ return { deps, reads, clock: () => clock };
88
+ }
89
+ let tmp;
90
+ const sessions = () => ({ list: () => ['agent-1'], cwdOf: () => tmp });
91
+ beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'shot-'))); });
92
+ afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
93
+ describe('screenshot folder resolution', () => {
94
+ it('defaults to ~/Desktop, expands ~, ignores relative junk', () => {
95
+ expect((0, screenshotAttach_1.resolveScreenshotFolder)(null, HOME)).toBe('/Users/me/Desktop');
96
+ expect((0, screenshotAttach_1.resolveScreenshotFolder)(' \n', HOME)).toBe('/Users/me/Desktop');
97
+ expect((0, screenshotAttach_1.resolveScreenshotFolder)('~/Pictures/Screenshots\n', HOME)).toBe('/Users/me/Pictures/Screenshots');
98
+ expect((0, screenshotAttach_1.resolveScreenshotFolder)('~', HOME)).toBe('/Users/me');
99
+ expect((0, screenshotAttach_1.resolveScreenshotFolder)('/Volumes/Shots/', HOME)).toBe('/Volumes/Shots/');
100
+ expect((0, screenshotAttach_1.resolveScreenshotFolder)('Pictures', HOME)).toBe('/Users/me/Desktop');
101
+ });
102
+ it('accepts only a plain image basename', () => {
103
+ expect((0, screenshotAttach_1.isSafeScreenshotName)(NAME)).toBe(true);
104
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('shot.JPEG')).toBe(true);
105
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('shot.webp')).toBe(true);
106
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('../secret.png')).toBe(false);
107
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('a/b.png')).toBe(false);
108
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('a\\b.png')).toBe(false);
109
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('..png')).toBe(false);
110
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('.hidden.png')).toBe(false);
111
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('x.png\0.txt')).toBe(false);
112
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('shot.heic')).toBe(false);
113
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('notes.txt')).toBe(false);
114
+ expect((0, screenshotAttach_1.isSafeScreenshotName)('noext')).toBe(false);
115
+ expect((0, screenshotAttach_1.isSafeScreenshotName)(42)).toBe(false);
116
+ });
117
+ });
118
+ describe('handleScreenshotFrame', () => {
119
+ it('finds the file after macOS writes it (delay) and saves it via the attach writer', async () => {
120
+ const file = path.join('/Users/me/Pictures/Shots', NAME);
121
+ const f = fakeDeps({ location: '~/Pictures/Shots', files: { [file]: { data: PNG, at: 1500 } } });
122
+ const reply = await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r1', sessionId: 'agent-1', name: NAME }, sessions(), f.deps);
123
+ expect(reply).toMatchObject({ t: 'file-attached', rid: 'r1', sessionId: 'agent-1', ok: true, bytes: PNG.length });
124
+ expect(f.clock()).toBe(1500);
125
+ const saved = String(reply.path);
126
+ expect(path.dirname(saved)).toBe(path.join(tmp, attachmentWriter_1.ATTACHMENTS_DIR));
127
+ expect(saved.endsWith('-Screenshot-2026-10-04-at-9.41.12-AM.png')).toBe(true);
128
+ expect(fs.readFileSync(saved).equals(PNG)).toBe(true);
129
+ expect(f.reads).toEqual([file]);
130
+ });
131
+ it('defaults to ~/Desktop when no location is set', async () => {
132
+ const f = fakeDeps({ files: { [path.join('/Users/me/Desktop', NAME)]: { data: PNG } } });
133
+ const reply = await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: NAME }, sessions(), f.deps);
134
+ expect(reply.ok).toBe(true);
135
+ expect(f.clock()).toBe(0);
136
+ });
137
+ it('gives up with not-found after ~6s', async () => {
138
+ const f = fakeDeps({});
139
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: NAME }, sessions(), f.deps))
140
+ .toEqual({ t: 'file-attached', rid: 'r', sessionId: 'agent-1', ok: false, reason: 'not-found' });
141
+ expect(f.clock()).toBe(screenshotAttach_1.SCREENSHOT_POLL_MS);
142
+ expect(fs.existsSync(path.join(tmp, '.hiveop'))).toBe(false);
143
+ });
144
+ it('rejects path traversal without touching the disk', async () => {
145
+ const f = fakeDeps({ files: { '/Users/me/secret.png': { data: PNG } } });
146
+ for (const name of ['../secret.png', '/Users/me/secret.png', 'sub/../../secret.png']) {
147
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name }, sessions(), f.deps))
148
+ .toMatchObject({ ok: false, reason: 'bad-name' });
149
+ }
150
+ expect(f.reads).toEqual([]);
151
+ });
152
+ it('rejects non-image names and non-image bytes', async () => {
153
+ const file = path.join('/Users/me/Desktop', 'shot.png');
154
+ const f = fakeDeps({ files: { [file]: { data: Buffer.from('not an image at all') } } });
155
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: 'notes.txt' }, sessions(), f.deps))
156
+ .toMatchObject({ ok: false, reason: 'bad-name' });
157
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: 'shot.heic' }, sessions(), f.deps))
158
+ .toMatchObject({ ok: false, reason: 'bad-name' });
159
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: 'shot.png' }, sessions(), f.deps))
160
+ .toMatchObject({ ok: false, reason: 'not-image' });
161
+ expect(fs.existsSync(path.join(tmp, '.hiveop'))).toBe(false);
162
+ });
163
+ it('refuses a symlink/dir by that name, an oversize file, an unknown session, and non-macOS', async () => {
164
+ const file = path.join('/Users/me/Desktop', 'shot.png');
165
+ const link = fakeDeps({ files: { [file]: { data: PNG, isFile: false } } });
166
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: 'shot.png' }, sessions(), link.deps))
167
+ .toMatchObject({ ok: false, reason: 'not-image' });
168
+ expect(link.reads).toEqual([]);
169
+ const big = fakeDeps({ files: { [file]: { data: Buffer.alloc(attachmentWriter_1.MAX_ATTACHMENT_BYTES + 1) } } });
170
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: 'shot.png' }, sessions(), big.deps))
171
+ .toMatchObject({ ok: false, reason: 'too-large' });
172
+ expect(big.reads).toEqual([]);
173
+ const ok = fakeDeps({ files: { [file]: { data: PNG } } });
174
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'nope', name: 'shot.png' }, sessions(), ok.deps))
175
+ .toMatchObject({ ok: false, reason: 'no-session' });
176
+ const linux = fakeDeps({ platform: 'linux', files: { [file]: { data: PNG } } });
177
+ expect(await (0, screenshotAttach_1.handleScreenshotFrame)({ rid: 'r', sessionId: 'agent-1', name: 'shot.png' }, sessions(), linux.deps))
178
+ .toMatchObject({ ok: false, reason: 'not-supported' });
179
+ });
180
+ });
181
+ function frameQueue(ws) {
182
+ const frames = [];
183
+ const waiters = [];
184
+ ws.on('message', (raw) => {
185
+ const f = JSON.parse(String(raw));
186
+ const w = waiters.shift();
187
+ if (w)
188
+ w(f);
189
+ else
190
+ frames.push(f);
191
+ });
192
+ return { next: () => (frames.length ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r))) };
193
+ }
194
+ describe('local bridge server — attach-screenshot frame', () => {
195
+ let handle;
196
+ afterEach(async () => { await handle?.close(); });
197
+ it('answers over the loopback socket and advertises attach-screenshot on macOS', async () => {
198
+ const pty = new FakePty();
199
+ const mgr = new sessionManager_1.SessionManager({ spawn: () => pty });
200
+ const f = fakeDeps({ files: { [path.join('/Users/me/Desktop', NAME)]: { data: PNG, at: 500 } } });
201
+ handle = await (0, server_1.startBridgeServer)({ sessionToken: 'tok', allowedOrigin: 'https://app.hiveop.io', sessions: mgr, screenshotDeps: f.deps });
202
+ const sessionId = mgr.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-1' });
203
+ const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: 'https://app.hiveop.io' } });
204
+ expect((await res.json()).capabilities).toEqual(expect.arrayContaining(['terminal-attach', 'attach-screenshot']));
205
+ const ws = new ws_1.default(`ws://127.0.0.1:${handle.port}`, { headers: { origin: 'https://app.hiveop.io' } });
206
+ await new Promise((r) => ws.on('open', r));
207
+ const q = frameQueue(ws);
208
+ ws.send(JSON.stringify({ v: 1, token: 'tok' }));
209
+ ws.send(JSON.stringify({ t: 'attach-screenshot', rid: 's1', sessionId, name: NAME }));
210
+ const reply = await q.next();
211
+ expect(reply).toMatchObject({ t: 'file-attached', rid: 's1', sessionId, ok: true });
212
+ expect(fs.readFileSync(reply.path).equals(PNG)).toBe(true);
213
+ expect(pty.written).toEqual([]);
214
+ ws.close();
215
+ });
216
+ it('does not advertise attach-screenshot off macOS', async () => {
217
+ const mgr = new sessionManager_1.SessionManager({ spawn: () => new FakePty() });
218
+ handle = await (0, server_1.startBridgeServer)({
219
+ sessionToken: 'tok', allowedOrigin: 'https://app.hiveop.io', sessions: mgr,
220
+ screenshotDeps: fakeDeps({ platform: 'linux' }).deps,
221
+ });
222
+ const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: 'https://app.hiveop.io' } });
223
+ const caps = (await res.json()).capabilities;
224
+ expect(caps).toContain('terminal-attach');
225
+ expect(caps).not.toContain('attach-screenshot');
226
+ });
227
+ });
228
+ describe('relay client (hosted) — never answers attach-screenshot', () => {
229
+ it('sends no file-attached for the frame', async () => {
230
+ const { WebSocketServer } = await Promise.resolve().then(() => __importStar(require('ws')));
231
+ const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' });
232
+ await new Promise((r) => wss.on('listening', r));
233
+ const port = wss.address().port;
234
+ const conn = new Promise((r) => wss.on('connection', r));
235
+ const mgr = new sessionManager_1.SessionManager({ spawn: () => new FakePty() });
236
+ mgr.open({ cols: 80, rows: 24, cwd: tmp, id: 'agent-H' });
237
+ const client = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${port}`, relayToken: 'tok', sessions: mgr });
238
+ const ws = await conn;
239
+ const got = [];
240
+ ws.on('message', (raw) => got.push(JSON.parse(String(raw))));
241
+ await new Promise((r) => setTimeout(r, 50)); // auth frame
242
+ ws.send(JSON.stringify({ t: 'attach-screenshot', rid: 'h1', sessionId: 'agent-H', name: 'shot.png' }));
243
+ await new Promise((r) => setTimeout(r, 200));
244
+ expect(got.filter((f) => f.t === 'file-attached')).toEqual([]);
245
+ client.close();
246
+ for (const c of wss.clients)
247
+ c.terminate();
248
+ await new Promise((r) => wss.close(r));
249
+ });
250
+ });
@@ -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 {};