workbite 0.1.0 → 0.1.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/lib/mcp.js ADDED
@@ -0,0 +1,501 @@
1
+ 'use strict';
2
+ // `workbite mcp`: a minimal MCP stdio server (newline-delimited JSON-RPC 2.0) over the
3
+ // WorkBite agent binding. The binding is resolved once per process and never retargets;
4
+ // every tool call becomes exactly one executeAgent request, run one at a time, never
5
+ // replayed. stdout carries protocol messages only; diagnostics go to stderr.
6
+ //
7
+ // Memory is bounded: at most LIMITS.queue complete lines (LIMITS.queueBytes in total) wait
8
+ // behind the one message in flight, plus one partial line (LIMITS.message) and at most one
9
+ // undelivered input chunk. When that is full stdin is paused (backpressure), never dropped;
10
+ // the next response is not produced until stdout has drained.
11
+
12
+ const SUPPORTED_VERSIONS = Object.freeze(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
13
+ const LATEST_VERSION = SUPPORTED_VERSIONS[0];
14
+ // structuredContent, outputSchema and tool titles exist from 2025-06-18; annotations from 2025-03-26.
15
+ const STRUCTURED_SINCE = '2025-06-18', ANNOTATIONS_SINCE = '2025-03-26';
16
+ // Only the 2025-03-26 schema defines JSONRPCBatchRequest ("MUST support receiving JSON-RPC batches").
17
+ // 2024-11-05 defines no batch message and 2025-06-18 removed them, so batches are refused there.
18
+ const BATCH_VERSION = '2025-03-26';
19
+ const LIMITS = Object.freeze({ message: 1024 * 1024, packet: 32768, stdout: 256 * 1024, stderr: 8 * 1024,
20
+ batch: 16, queue: 16, queueBytes: 4 * 1024 * 1024 });
21
+ const MCP_CONTRACT = 'workbite.mcp.v1', CLI_CONTRACT = 'workstack.cli.v1';
22
+ const WRITES = new Set(['agent.tasks.create', 'agent.tasks.update', 'agent.checkpoint']);
23
+ // Owner error codes the native agent contract publishes; anything else is reported as internal_error.
24
+ const KNOWN_ERRORS = new Set(['action_conflict', 'action_pending', 'backend_mismatch', 'capability_not_enabled',
25
+ 'commit_unknown', 'context_too_large', 'generation_changed', 'idempotency_conflict', 'input_unreadable',
26
+ 'internal_error', 'invalid_authority', 'invalid_body', 'invalid_receipt', 'invalid_response', 'journal_invalid',
27
+ 'journal_unavailable', 'launcher_unavailable', 'not_configured', 'not_found', 'not_sent', 'offline',
28
+ 'operation_refused', 'owner_disconnected', 'owner_unavailable', 'owner_upgrade_required', 'provider_unavailable',
29
+ 'recovery_required', 'refused', 'rejected', 'request_conflict', 'review_pending', 'revision_conflict',
30
+ 'room_mismatch', 'room_unavailable', 'selection_invalid', 'stale_task', 'store_sync_required', 'task_not_found',
31
+ 'unauthorized', 'unknown_action', 'unknown_request', 'unsupported', 'wait_unsupported', 'workspace_mismatch']);
32
+ const SECRET_KEY = /token|secret|password|passwd|authorization|cookie|csrf|credential|api[_-]?key/i;
33
+
34
+ const MESSAGES = Object.freeze({
35
+ invalid_arguments: 'tool arguments violate the declared input schema; nothing was run',
36
+ workspace_mismatch: 'workspace_uid does not match the workspace this server is bound to; nothing was run',
37
+ binding_unavailable: 'no verified WorkBite workspace is bound to this server; open WorkBite and restart the MCP server',
38
+ not_sent: 'the request was refused before it reached the owner; nothing was written',
39
+ commit_unknown: 'the write may or may not be recorded; do not repeat it yet. First look up the SAME intent_id with '
40
+ + 'workbite_operation_status. committed: continue from that receipt. unknown with reason no_receipt: you may '
41
+ + 'resend once with the SAME intent_id and byte-identical arguments. Any other answer or reason: stop and '
42
+ + 'report it. Never switch to a new intent_id',
43
+ cli_output_invalid: 'the WorkBite agent answered without a valid bound envelope',
44
+ cli_usage_refused: 'the WorkBite agent refused the command line',
45
+ call_failed: 'the WorkBite agent call failed',
46
+ });
47
+ const INSTRUCTIONS = 'WorkBite tools act on the one WorkBite workspace bound when this server started; '
48
+ + 'changing the selected workspace later does not retarget them. Use workbite_status to see the bound '
49
+ + 'workspace and its workspace_uid. Create as many Tasks as the user asked for with workbite_tasks_create, one '
50
+ + 'call per Task, each with its own stable intent_id; no Task selection or extra confirmation is needed for '
51
+ + 'those. To read, update or checkpoint an EXISTING Task, list Tasks with workbite_tasks_list and act only on the '
52
+ + 'Task the user explicitly selected (workbite_context, workbite_tasks_update, workbite_checkpoint). Writes need '
53
+ + 'the bound workspace_uid and a caller-chosen intent_id that stays the SAME for the SAME change; updates also '
54
+ + 'need expected_revision from a fresh read. Calls run one at a time and are never retried automatically. After '
55
+ + 'commit_unknown, first look the SAME intent_id up with workbite_operation_status: continue from a committed '
56
+ + 'receipt; only unknown with reason no_receipt permits one resend with the SAME intent_id and byte-identical '
57
+ + 'arguments; on any other answer stop and report it.';
58
+
59
+ const TASK_ID = { type: 'string', pattern: '^T-[0-9]{4,}$', maxLength: 64, description: 'Explicitly selected Task ID, for example T-0001' };
60
+ const INTENT_ID = { type: 'string', pattern: '^[A-Za-z0-9._:-]{8,128}$',
61
+ description: 'Caller-chosen idempotency key, stable across retries of the SAME change' };
62
+ const WORKSPACE_UID = { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
63
+ description: 'The bound workspace UID reported by workbite_status' };
64
+ const ITEMS = { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 1000 } };
65
+ const object = (properties, required = []) => ({ type: 'object', additionalProperties: false, properties, required });
66
+ const OUTPUT_SCHEMA = { type: 'object', required: ['contract', 'meta'], properties: {
67
+ contract: { type: 'string', enum: [CLI_CONTRACT, MCP_CONTRACT] }, data: { type: 'object' },
68
+ error: { type: 'object' }, meta: { type: 'object' }, workbite: { type: 'object' } } };
69
+
70
+ const TOOLS = [
71
+ { name: 'workbite_status', command: 'agent.status', title: 'WorkBite status', readOnly: true,
72
+ description: 'Report whether the bound WorkBite workspace owner is ready, plus the bound workspace path and workspace_uid.',
73
+ inputSchema: object({ workspace_uid: WORKSPACE_UID }) },
74
+ { name: 'workbite_tasks_list', command: 'agent.tasks.list', title: 'List WorkBite Tasks', readOnly: true,
75
+ description: 'List bounded Task metadata (id, title, status, revision) so the user can pick one Task. Never selects a Task.',
76
+ inputSchema: object({ workspace_uid: WORKSPACE_UID,
77
+ status: { type: 'string', enum: ['active', 'all', 'open', 'started', 'done', 'dropped'], default: 'active' },
78
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
79
+ offset: { type: 'integer', minimum: 0, maximum: 1000000, default: 0 } }) },
80
+ { name: 'workbite_context', command: 'agent.context', title: 'Read one WorkBite Task context', readOnly: true,
81
+ description: 'Read the bounded context of one explicitly selected Task. view resume-v1 includes the latest checkpoint.',
82
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, task_id: TASK_ID,
83
+ view: { type: 'string', enum: ['core-v1', 'planning-v1', 'planning-v2', 'resume-v1'], default: 'core-v1' },
84
+ fingerprint: { type: 'boolean', default: false },
85
+ if_fingerprint: { type: 'string', pattern: '^v1:[0-9a-f]{64}$' } }, ['task_id']) },
86
+ { name: 'workbite_tasks_create', command: 'agent.tasks.create', title: 'Create one WorkBite Task', readOnly: false,
87
+ description: 'Create one Task. Durable per intent_id: the SAME intent_id with identical arguments replays the one created Task.',
88
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID,
89
+ title: { type: 'string', minLength: 1, maxLength: 32768 }, detail: { type: 'string', maxLength: 32768 },
90
+ follows_task_id: TASK_ID }, ['workspace_uid', 'intent_id', 'title']) },
91
+ { name: 'workbite_tasks_update', command: 'agent.tasks.update', title: 'Update one WorkBite Task', readOnly: false,
92
+ description: 'Change the detail and/or status of one Task read at expected_revision (compare-and-set). '
93
+ + 'A stale revision is revision_conflict and nothing is written.',
94
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID, task_id: TASK_ID,
95
+ expected_revision: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
96
+ changes: { type: 'object', additionalProperties: false, minProperties: 1, properties: {
97
+ detail: { type: 'string', maxLength: 32768 },
98
+ status: { type: 'string', enum: ['open', 'started', 'done', 'dropped'] } } } },
99
+ ['workspace_uid', 'intent_id', 'task_id', 'expected_revision', 'changes']) },
100
+ { name: 'workbite_checkpoint', command: 'agent.checkpoint', title: 'Append a WorkBite checkpoint', readOnly: false,
101
+ description: 'Append one Daily Review checkpoint to an explicitly selected Task. Idempotent per intent_id.',
102
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID, task_id: TASK_ID,
103
+ date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'ISO calendar date YYYY-MM-DD' },
104
+ done: ITEMS, next: ITEMS, blockers: ITEMS },
105
+ ['workspace_uid', 'intent_id', 'task_id', 'date', 'done', 'next', 'blockers']) },
106
+ { name: 'workbite_operation_status', command: 'agent.operation-status', title: 'Look up a WorkBite write receipt',
107
+ readOnly: true, description: 'Read-only lookup of the committed receipt of one intent_id. unknown never means "did not commit".',
108
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID }, ['intent_id']) },
109
+ ];
110
+ const BY_NAME = new Map(TOOLS.map(tool => [tool.name, tool]));
111
+ const PACKET_KEYS = { 'agent.tasks.create': ['title', 'detail', 'follows_task_id'],
112
+ 'agent.tasks.update': ['task_id', 'expected_revision', 'changes'],
113
+ 'agent.checkpoint': ['task_id', 'date', 'done', 'next', 'blockers'] };
114
+
115
+ const isPlain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
116
+ // The schema subset above, enforced server side; the first violation is reported.
117
+ function violation(value, schema, where) {
118
+ switch (schema.type) {
119
+ case 'object': {
120
+ if (!isPlain(value)) return `${where} must be an object`;
121
+ for (const key of schema.required || []) if (!Object.hasOwn(value, key)) return `${where}.${key} is required`;
122
+ if (Object.keys(value).length < (schema.minProperties || 0)) return `${where} has too few properties`;
123
+ for (const [key, item] of Object.entries(value)) {
124
+ if (!Object.hasOwn(schema.properties, key)) return `${where} has unknown property ${JSON.stringify(key.slice(0, 64))}`;
125
+ const problem = violation(item, schema.properties[key], `${where}.${key}`);
126
+ if (problem) return problem;
127
+ }
128
+ return null;
129
+ }
130
+ case 'array': {
131
+ if (!Array.isArray(value)) return `${where} must be an array`;
132
+ if (value.length > schema.maxItems) return `${where} has too many items`;
133
+ for (let index = 0; index < value.length; index++) {
134
+ const problem = violation(value[index], schema.items, `${where}[${index}]`);
135
+ if (problem) return problem;
136
+ }
137
+ return null;
138
+ }
139
+ case 'integer':
140
+ if (!Number.isSafeInteger(value)) return `${where} must be an integer`;
141
+ if (value < schema.minimum || value > schema.maximum) return `${where} is out of range`;
142
+ return null;
143
+ case 'boolean': return typeof value === 'boolean' ? null : `${where} must be a boolean`;
144
+ case 'string': {
145
+ if (typeof value !== 'string') return `${where} must be a string`;
146
+ if (value.includes('\0') || value.isWellFormed?.() === false) return `${where} is not valid text`;
147
+ const length = [...value].length;
148
+ if (length < (schema.minLength || 0) || length > (schema.maxLength ?? Infinity)) return `${where} has an invalid length`;
149
+ if (schema.enum && !schema.enum.includes(value)) return `${where} is not an allowed value`;
150
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) return `${where} has an invalid format`;
151
+ return null;
152
+ }
153
+ default: return `${where} has an unsupported schema`;
154
+ }
155
+ }
156
+
157
+ // Native agent argv for the pinned workstack-native-client `cli.execute`; packets travel on stdin only.
158
+ // The command words come immediately after `agent` (agent.js commandOf reads them up to the first flag);
159
+ // the bound workspace UID follows them as an identity flag that the native parser checks position-free.
160
+ function nativeRequest(tool, args, uid) {
161
+ const argv = ['agent', ...tool.command.split('.').slice(1), '--workspace-uid', uid];
162
+ let stdin;
163
+ switch (tool.command) {
164
+ case 'agent.status': break;
165
+ case 'agent.tasks.list':
166
+ argv.push(`--status=${args.status ?? 'active'}`, `--limit=${args.limit ?? 20}`, `--offset=${args.offset ?? 0}`);
167
+ break;
168
+ case 'agent.context':
169
+ argv.push(`--task=${args.task_id}`, `--view=${args.view ?? 'core-v1'}`, '--format=json');
170
+ if (args.fingerprint === true) argv.push('--fingerprint');
171
+ if (args.if_fingerprint !== undefined) argv.push(`--if-fingerprint=${args.if_fingerprint}`);
172
+ break;
173
+ case 'agent.operation-status': argv.push(`--intent-id=${args.intent_id}`); break;
174
+ default: {
175
+ argv.push(`--intent-id=${args.intent_id}`, '--stdin');
176
+ const packet = {};
177
+ for (const key of PACKET_KEYS[tool.command]) if (Object.hasOwn(args, key)) packet[key] = args[key];
178
+ stdin = JSON.stringify(packet);
179
+ }
180
+ }
181
+ return { argv, stdin };
182
+ }
183
+
184
+ function redact(value) {
185
+ if (Array.isArray(value)) return value.map(redact);
186
+ if (!isPlain(value)) return value;
187
+ const out = {};
188
+ for (const [key, item] of Object.entries(value)) out[key] = SECRET_KEY.test(key) ? '[redacted]' : redact(item);
189
+ return out;
190
+ }
191
+ function adapterError(command, code, args, extra = {}) {
192
+ const meta = { command };
193
+ if (args && typeof args.intent_id === 'string') meta.intent_id = args.intent_id;
194
+ if (args && typeof args.task_id === 'string') meta.task_id = args.task_id;
195
+ if (code === 'commit_unknown') meta.commit_state = 'unknown';
196
+ const error = { code, message: MESSAGES[code], retryable: false };
197
+ if (extra.reason) error.details = { reason: extra.reason };
198
+ return { contract: MCP_CONTRACT, error, meta };
199
+ }
200
+ function parseEnvelope(text) {
201
+ if (typeof text !== 'string' || Buffer.byteLength(text) > LIMITS.stdout || !text.endsWith('\n')
202
+ || text.indexOf('\n') !== text.length - 1) return null;
203
+ try { const value = JSON.parse(text); return isPlain(value) ? value : null; } catch { return null; }
204
+ }
205
+ // The envelope must answer this command for this workspace (and this intent when one was sent).
206
+ function bound(envelope, tool, args, uid, success) {
207
+ const meta = envelope.meta;
208
+ if (envelope.contract !== CLI_CONTRACT || !isPlain(meta) || meta.command !== tool.command) return false;
209
+ if (meta.intent_id !== undefined && args.intent_id !== undefined && meta.intent_id !== args.intent_id) return false;
210
+ if (success) return isPlain(envelope.data) && envelope.error === undefined && meta.workspace_uid === uid;
211
+ return isPlain(envelope.error) && typeof envelope.error.code === 'string' && envelope.data === undefined
212
+ && (meta.workspace_uid === undefined || meta.workspace_uid === null || meta.workspace_uid === uid);
213
+ }
214
+
215
+ function createServer({ agent, bindOptions, stderr }) {
216
+ let initialized = false, version = LATEST_VERSION;
217
+ let binding;
218
+ const log = message => { try { stderr.write(`workbite mcp: ${message}\n`); } catch { /* stderr closed */ } };
219
+ // Resolved once per process; a later selection change cannot retarget this server.
220
+ const pinned = (async () => {
221
+ try {
222
+ const value = await agent().bindAgent({ ...bindOptions });
223
+ if (!value || typeof value.workspaceUid !== 'string' || typeof value.workspace !== 'string') throw new Error('invalid binding');
224
+ binding = Object.freeze(value);
225
+ return binding;
226
+ } catch (error) {
227
+ log(`no verified workspace binding (${errorCode(error)})`);
228
+ return null;
229
+ }
230
+ })();
231
+
232
+ function toolResult(envelope, isError) {
233
+ const clean = redact(envelope);
234
+ const result = { content: [{ type: 'text', text: JSON.stringify(clean) }], isError };
235
+ if (version >= STRUCTURED_SINCE) result.structuredContent = clean;
236
+ return result;
237
+ }
238
+ function describe(tool) {
239
+ const out = { name: tool.name, description: tool.description, inputSchema: tool.inputSchema };
240
+ if (version >= STRUCTURED_SINCE) { out.title = tool.title; out.outputSchema = OUTPUT_SCHEMA; }
241
+ if (version >= ANNOTATIONS_SINCE) {
242
+ out.annotations = { title: tool.title, readOnlyHint: tool.readOnly, destructiveHint: false,
243
+ idempotentHint: true, openWorldHint: false };
244
+ }
245
+ return out;
246
+ }
247
+
248
+ async function call(tool, args) {
249
+ const problem = violation(args, tool.inputSchema, 'arguments');
250
+ if (problem) return toolResult(adapterError(tool.command, 'invalid_arguments', null, { reason: problem }), true);
251
+ const target = await pinned;
252
+ if (!target) return toolResult(adapterError(tool.command, 'binding_unavailable', args), true);
253
+ if (args.workspace_uid !== undefined && args.workspace_uid !== target.workspaceUid) {
254
+ return toolResult(adapterError(tool.command, 'workspace_mismatch', args), true);
255
+ }
256
+ const request = nativeRequest(tool, args, target.workspaceUid);
257
+ if (request.stdin !== undefined && Buffer.byteLength(request.stdin) > LIMITS.packet) {
258
+ return toolResult(adapterError(tool.command, 'invalid_arguments', null,
259
+ { reason: `packet exceeds ${LIMITS.packet} bytes` }), true);
260
+ }
261
+ const write = WRITES.has(tool.command);
262
+ let native;
263
+ try {
264
+ native = await agent().executeAgent(target, request.argv, request.stdin === undefined ? {} : { stdin: request.stdin });
265
+ } catch (error) {
266
+ const code = errorCode(error);
267
+ log(`${tool.name} failed (${code})`);
268
+ // A write that may have been sent has an unknown outcome; it is never replayed here.
269
+ if (write && code !== 'not_sent' && error?.sent !== false) {
270
+ return toolResult(adapterError(tool.command, 'commit_unknown', args), true);
271
+ }
272
+ const reported = write ? 'not_sent' : (KNOWN_ERRORS.has(code) ? code : 'call_failed');
273
+ return toolResult({ contract: MCP_CONTRACT, error: { code: reported, message: MESSAGES[reported] || reported,
274
+ retryable: false }, meta: { command: tool.command, ...(args.intent_id ? { intent_id: args.intent_id } : {}) } }, true);
275
+ }
276
+ const invalid = () => toolResult(adapterError(tool.command, write ? 'commit_unknown' : 'cli_output_invalid', args), true);
277
+ if (!isPlain(native) || ![0, 1, 2].includes(native.code)) return invalid();
278
+ if (typeof native.stderr === 'string' && native.stderr && Buffer.byteLength(native.stderr) > LIMITS.stderr) return invalid();
279
+ const envelope = parseEnvelope(native.stdout);
280
+ if (!envelope) {
281
+ // Exit 2 without an envelope is a usage refusal before any request was admitted.
282
+ if (native.code === 2 && !native.stdout) return toolResult(adapterError(tool.command, 'cli_usage_refused', args), true);
283
+ return invalid();
284
+ }
285
+ if (!bound(envelope, tool, args, target.workspaceUid, native.code === 0)) return invalid();
286
+ if (native.code === 0) {
287
+ if (tool.command === 'agent.status') {
288
+ envelope.workbite = { workspace: target.workspace, workspace_uid: target.workspaceUid,
289
+ ...(typeof target.endpoint === 'string' ? { endpoint: target.endpoint } : {}) };
290
+ }
291
+ return toolResult(envelope, false);
292
+ }
293
+ if (!KNOWN_ERRORS.has(envelope.error.code)) envelope.error = { ...envelope.error, code: 'internal_error' };
294
+ return toolResult(envelope, true);
295
+ }
296
+
297
+ const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
298
+ const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
299
+
300
+ // One message, or one 2025-03-26 batch run element by element in order (never concurrently).
301
+ // A batch answers only its requests; a batch of notifications gets no response at all.
302
+ async function receive(message) {
303
+ if (!Array.isArray(message)) return answer(message, false);
304
+ if (!initialized || version !== BATCH_VERSION) {
305
+ return failure(null, -32600, `Invalid Request: JSON-RPC batches are accepted only under protocol ${BATCH_VERSION}`);
306
+ }
307
+ if (!message.length) return failure(null, -32600, 'Invalid Request: empty batch');
308
+ if (message.length > LIMITS.batch) return failure(null, -32600, `Invalid Request: batch exceeds ${LIMITS.batch} messages`);
309
+ const replies = [];
310
+ for (const item of message) {
311
+ const result = await answer(item, true);
312
+ if (result) replies.push(result);
313
+ }
314
+ return replies.length ? replies : null;
315
+ }
316
+ async function answer(message, batched) {
317
+ try { return await handle(message, batched); } catch (error) {
318
+ log(`internal error (${errorCode(error)})`);
319
+ return failure(isPlain(message) && (typeof message.id === 'string' || Number.isSafeInteger(message.id)) ? message.id : null,
320
+ -32603, 'Internal error');
321
+ }
322
+ }
323
+
324
+ async function handle(message, batched) {
325
+ if (!isPlain(message)) return failure(null, -32600, 'Invalid Request');
326
+ const hasId = Object.hasOwn(message, 'id');
327
+ const id = hasId ? message.id : null;
328
+ if (hasId && !(typeof id === 'string' || Number.isSafeInteger(id))) {
329
+ return failure(null, -32600, 'Invalid Request: id must be a string or integer');
330
+ }
331
+ if (message.jsonrpc !== '2.0') return failure(id, -32600, 'Invalid Request');
332
+ if (message.method === undefined && hasId && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))) return null;
333
+ if (typeof message.method !== 'string') return failure(id, -32600, 'Invalid Request');
334
+ if (!hasId) return null; // notifications (initialized, cancelled, …) need no answer; running calls are not cancelled
335
+ const params = message.params === undefined ? {} : message.params;
336
+ if (!isPlain(params)) return failure(id, -32602, 'Invalid params: params must be an object');
337
+ switch (message.method) {
338
+ case 'ping': return reply(id, {});
339
+ case 'initialize': {
340
+ if (batched) return failure(id, -32600, 'Invalid Request: initialize must not be part of a batch');
341
+ if (initialized) return failure(id, -32600, 'Invalid Request: already initialized');
342
+ if (typeof params.protocolVersion !== 'string' || !isPlain(params.capabilities) || !isPlain(params.clientInfo)) {
343
+ return failure(id, -32602, 'Invalid params: initialize');
344
+ }
345
+ version = SUPPORTED_VERSIONS.includes(params.protocolVersion) ? params.protocolVersion : LATEST_VERSION;
346
+ initialized = true;
347
+ return reply(id, { protocolVersion: version, capabilities: { tools: { listChanged: false } },
348
+ serverInfo: { name: 'workbite', version: packageVersion() }, instructions: INSTRUCTIONS });
349
+ }
350
+ case 'tools/list':
351
+ if (!initialized) return failure(id, -32600, 'Invalid Request: server not initialized');
352
+ if (params.cursor !== undefined && params.cursor !== null && typeof params.cursor !== 'string') {
353
+ return failure(id, -32602, 'Invalid params: cursor');
354
+ }
355
+ return reply(id, { tools: TOOLS.map(describe) });
356
+ case 'tools/call': {
357
+ if (!initialized) return failure(id, -32600, 'Invalid Request: server not initialized');
358
+ const tool = typeof params.name === 'string' ? BY_NAME.get(params.name) : undefined;
359
+ if (!tool) return failure(id, -32602, 'Unknown tool');
360
+ const args = params.arguments === undefined ? {} : params.arguments;
361
+ if (!isPlain(args)) return failure(id, -32602, 'Invalid params: arguments must be an object');
362
+ return reply(id, await call(tool, args));
363
+ }
364
+ default: return failure(id, -32601, 'Method not found');
365
+ }
366
+ }
367
+ return { receive, pinned, log };
368
+ }
369
+
370
+ function errorCode(error) {
371
+ const code = error && typeof error.code === 'string' ? error.code : '';
372
+ return /^[a-z_]{1,64}$/.test(code) ? code : 'internal_error';
373
+ }
374
+ function packageVersion() {
375
+ try { return require('../package.json').version; } catch { return '0.0.0'; }
376
+ }
377
+
378
+ const tooLarge = id => ({ jsonrpc: '2.0', id: id ?? null, error: { code: -32603,
379
+ message: 'Internal error: response too large; look up any write with workbite_operation_status' } });
380
+ // One output line of at most LIMITS.message bytes; an oversized batch reply shrinks element by element.
381
+ function encode(message) {
382
+ const text = JSON.stringify(message);
383
+ if (Buffer.byteLength(text) + 1 <= LIMITS.message) return text;
384
+ if (!Array.isArray(message)) return JSON.stringify(tooLarge(message.id));
385
+ const parts = [];
386
+ let size = 3;
387
+ for (const item of message) {
388
+ let part = JSON.stringify(item);
389
+ if (size + Buffer.byteLength(part) + 1 > LIMITS.message) part = JSON.stringify(tooLarge(item.id));
390
+ parts.push(part); size += Buffer.byteLength(part) + 1;
391
+ }
392
+ return `[${parts.join(',')}]`;
393
+ }
394
+
395
+ // Serves until stdin ends, then finishes the queued messages. Always resolves {code:0, signal:null}.
396
+ // options.metrics (optional object) receives queue high-water marks for tests.
397
+ async function runMcp(options = {}) {
398
+ const stdin = options.stdin || process.stdin, stdout = options.stdout || process.stdout;
399
+ const stderr = options.stderr || process.stderr;
400
+ const metrics = options.metrics && typeof options.metrics === 'object' ? options.metrics : {};
401
+ Object.assign(metrics, { queueHighWater: 0, queueBytesHighWater: 0, paused: 0 });
402
+ let module = options.agent;
403
+ const agent = () => module || (module = require('./agent'));
404
+ const bindOptions = {};
405
+ for (const key of ['installed', 'stateRoot', 'environment', 'workspace', 'workspaceUid']) {
406
+ if (options[key] !== undefined) bindOptions[key] = options[key];
407
+ }
408
+ const server = createServer({ agent, bindOptions, stderr });
409
+ const decoder = new TextDecoder('utf-8', { fatal: true });
410
+
411
+ // Output: one response at a time; the next message is not processed until stdout drains.
412
+ let open = true, wake = null;
413
+ const closed = () => { open = false; if (wake) wake(); };
414
+ stdout.on?.('error', closed);
415
+ stdout.on?.('close', closed);
416
+ const send = async message => {
417
+ if (!open || !message) return;
418
+ let accepted;
419
+ try { accepted = stdout.write(`${encode(message)}\n`); } catch { open = false; return; }
420
+ if (accepted || !open) return;
421
+ await new Promise(resolve => {
422
+ wake = () => { stdout.removeListener('drain', wake); wake = null; resolve(); };
423
+ stdout.on('drain', wake);
424
+ });
425
+ };
426
+ const serve = async (bytes, oversize) => {
427
+ if (oversize) return send({ jsonrpc: '2.0', id: null,
428
+ error: { code: -32600, message: `Invalid Request: message exceeds ${LIMITS.message} bytes` } });
429
+ let text;
430
+ try { text = decoder.decode(bytes); } catch { return send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
431
+ if (!text.trim()) return undefined;
432
+ let message;
433
+ try { message = JSON.parse(text); } catch { return send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
434
+ return send(await server.receive(message));
435
+ };
436
+
437
+ // Input: complete lines wait in a bounded queue; when it is full the rest of the chunk is kept as
438
+ // `carry` and stdin is paused until the worker makes room. Nothing accepted is dropped.
439
+ const queue = [];
440
+ let queuedBytes = 0, carry = null, ended = false, working = false, skipped = 0, finish;
441
+ let pending = [], size = 0, oversize = false;
442
+ const done = new Promise(resolve => { finish = resolve; });
443
+ const room = length => queue.length === 0 || (queue.length < LIMITS.queue && queuedBytes + length <= LIMITS.queueBytes);
444
+ const consume = buffer => {
445
+ for (let index = buffer.indexOf(10); index !== -1; index = buffer.indexOf(10)) {
446
+ const part = buffer.subarray(0, index);
447
+ const over = oversize || size + part.length > LIMITS.message;
448
+ const length = over ? 0 : size + part.length;
449
+ if (!room(length)) return buffer;
450
+ let line = over ? null : Buffer.concat([...pending, part]);
451
+ if (line && line[line.length - 1] === 13) line = line.subarray(0, -1);
452
+ queue.push({ line, oversize: over, length });
453
+ queuedBytes += length;
454
+ metrics.queueHighWater = Math.max(metrics.queueHighWater, queue.length);
455
+ metrics.queueBytesHighWater = Math.max(metrics.queueBytesHighWater, queuedBytes);
456
+ pending = []; size = 0; oversize = false;
457
+ buffer = buffer.subarray(index + 1);
458
+ }
459
+ if (!buffer.length) return null;
460
+ if (oversize || size + buffer.length > LIMITS.message) { oversize = true; pending = []; size = 0; return null; }
461
+ pending.push(Buffer.from(buffer)); size += buffer.length;
462
+ return null;
463
+ };
464
+ const flow = () => {
465
+ if (carry) carry = consume(carry);
466
+ if (carry || queue.length >= LIMITS.queue) {
467
+ if (!stdin.isPaused?.()) { stdin.pause(); metrics.paused++; }
468
+ } else if (!ended) stdin.resume();
469
+ };
470
+ const work = async () => {
471
+ if (working) return;
472
+ working = true;
473
+ while (queue.length) {
474
+ const item = queue.shift();
475
+ queuedBytes -= item.length;
476
+ flow();
477
+ if (!open) { skipped++; continue; } // nobody can read the answer: queued work is not started
478
+ await serve(item.line, item.oversize);
479
+ }
480
+ working = false;
481
+ if (ended && !carry) finish();
482
+ };
483
+ stdin.on('data', chunk => {
484
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
485
+ carry = carry ? Buffer.concat([carry, buffer]) : buffer;
486
+ flow();
487
+ work();
488
+ });
489
+ const end = () => { ended = true; flow(); if (!working && !queue.length && !carry) finish(); else work(); };
490
+ stdin.on('end', end);
491
+ stdin.on('close', end);
492
+ stdin.on('error', error => { server.log(`stdin error (${errorCode(error)})`); end(); });
493
+ await done;
494
+ if (size || oversize) server.log('discarded an incomplete final line');
495
+ if (skipped) server.log(`stdout closed; ${skipped} queued message(s) were not run`);
496
+ if (wake) wake();
497
+ await server.pinned;
498
+ return { code: 0, signal: null };
499
+ }
500
+
501
+ module.exports = { runMcp, TOOLS, SUPPORTED_VERSIONS, LIMITS, nativeRequest, violation };
package/lib/runtime.js CHANGED
@@ -17,6 +17,7 @@ const BACKEND_SCHEMA = 'stackbite.workstack-backend.v1';
17
17
  const CONNECTION_SCHEMA = 'workstack.helm-connection.v1';
18
18
  const OWNER_SCHEMA = 'workbite.owner.v2';
19
19
  const STACKBITE_SCHEMA = 'workbite.stackbite-backend.v2';
20
+ const SELECTION_SCHEMA = 'workbite.workspace-selection.v1';
20
21
  const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
21
22
  // Ports of the existing official StackBite and its previews are never chosen.
22
23
  const RESERVED_PORTS = new Set([8089, 8090, 8091, 54036, 54039, 54236, 54239]);
@@ -46,7 +47,8 @@ function stateLayout(root) {
46
47
  return { root, stackbite: path.join(root, 'stackbite'), workstackRuntime: path.join(root, 'workstack-runtime'),
47
48
  journal: path.join(root, 'deploy-journal'), tuiState: path.join(root, 'workstack-tui'), owners: path.join(root, 'owners'),
48
49
  logs: path.join(root, 'logs'), dock: path.join(root, 'execution-dock.json'),
49
- stackbiteRecord: path.join(root, 'stackbite-backend.json'), lock: path.join(root, 'open.lock') };
50
+ stackbiteRecord: path.join(root, 'stackbite-backend.json'), lock: path.join(root, 'open.lock'),
51
+ selection: path.join(root, 'last-workspace.json') };
50
52
  }
51
53
  async function prepareState(layout) {
52
54
  for (const directory of [layout.root, layout.stackbite, layout.workstackRuntime, layout.journal, layout.tuiState,
@@ -118,6 +120,58 @@ async function initializeWorkspace(commands, workspace, environment) {
118
120
  return uid;
119
121
  }
120
122
 
123
+ // The workspace a bare `workbite` reopens: the last one opened successfully, or, for
124
+ // state from 0.1.0 with no saved selection, the single owner record it holds. It is
125
+ // never guessed (no CWD, mtime or default folder) and never silently replaced by
126
+ // another workspace; the genuine workspace.json id must still match.
127
+ const EXPLICIT = 'Open it once with: workbite open --workspace ABS';
128
+ async function currentUid(workspace, what) {
129
+ try { return await readWorkspaceUid(workspace); } catch (error) {
130
+ fail(`The ${what} ${workspace} is missing, moved or no longer a valid Work Stack workspace (${error.message}) ${EXPLICIT}`);
131
+ }
132
+ }
133
+ async function rememberedWorkspace(layout) {
134
+ let saved;
135
+ try { saved = await readJson(layout.selection, 8192); } catch (error) {
136
+ if (error.code !== 'ENOENT') fail(`The saved workspace selection ${layout.selection} is unreadable. ${EXPLICIT}`);
137
+ return ownerWorkspace(layout);
138
+ }
139
+ if (saved === null || typeof saved !== 'object' || Array.isArray(saved) || Object.keys(saved).length !== 3
140
+ || saved.schema !== SELECTION_SCHEMA || typeof saved.workspace !== 'string' || !path.isAbsolute(saved.workspace)
141
+ || typeof saved.workspace_uid !== 'string' || !UUID.test(saved.workspace_uid)) {
142
+ fail(`The saved workspace selection ${layout.selection} is invalid. ${EXPLICIT}`);
143
+ }
144
+ if (await currentUid(saved.workspace, 'last opened workspace') !== saved.workspace_uid) {
145
+ fail(`The last opened workspace ${saved.workspace} was replaced (its workspace.json id changed); WorkBite does not open it silently. ${EXPLICIT}`);
146
+ }
147
+ return { workspace: saved.workspace, uid: saved.workspace_uid };
148
+ }
149
+ async function ownerWorkspace(layout) {
150
+ let names = [];
151
+ try { names = (await fs.readdir(layout.owners)).filter(name => /^[0-9a-f]{24}\.json$/.test(name)); } catch (error) {
152
+ if (error.code !== 'ENOENT') fail(`WorkBite owner records are unreadable. ${EXPLICIT}`);
153
+ }
154
+ if (!names.length) fail(`No workspace has been opened yet. ${EXPLICIT} (add --init for a new or empty folder).`);
155
+ if (names.length > 1) fail(`WorkBite has ${names.length} workspace owner records and will not guess which one to open. ${EXPLICIT}`);
156
+ const file = path.join(layout.owners, names[0]);
157
+ let record;
158
+ try { record = await readJson(file); } catch { fail(`The Work Stack owner record ${file} is unreadable. ${EXPLICIT}`); }
159
+ if (!isRecord(record) || record.schema !== OWNER_SCHEMA || typeof record.workspace !== 'string' || !path.isAbsolute(record.workspace)
160
+ || `${workspaceKey(record.workspace)}.json` !== names[0] || typeof record.workspace_uid !== 'string' || !UUID.test(record.workspace_uid)) {
161
+ fail(`The Work Stack owner record ${file} is not a valid owner record. ${EXPLICIT}`);
162
+ }
163
+ if (await currentUid(record.workspace, 'workspace recorded in the owner record') !== record.workspace_uid) {
164
+ fail(`The workspace recorded in ${file} was replaced (its workspace.json id changed); WorkBite does not open it silently. ${EXPLICIT}`);
165
+ }
166
+ return { workspace: record.workspace, uid: record.workspace_uid };
167
+ }
168
+ async function rememberWorkspace(layout, workspace, uid) {
169
+ const bytes = Buffer.from(JSON.stringify({ schema: SELECTION_SCHEMA, workspace, workspace_uid: uid }, null, 2) + '\n');
170
+ let current = null;
171
+ try { current = await readSmall(layout.selection, 8192); } catch { current = null; }
172
+ if (!current || !current.equals(bytes)) await writeAtomic(layout.selection, bytes);
173
+ }
174
+
121
175
  function request(url, { method = 'GET', headers = {}, body, timeout = 5000 } = {}) {
122
176
  const match = LOOPBACK.exec(url.origin);
123
177
  if (!match) return Promise.reject(new Error('not a loopback origin'));
@@ -370,18 +424,27 @@ function runForeground(command, args, environment, { spawnImpl = spawn, signalSo
370
424
  // commands maps each bundle executable to its argv prefix; tests substitute fixtures.
371
425
  async function openSuite({ installed, commands, workspace, init = false, stateRoot, environment = process.env,
372
426
  ui = true, spawnImpl = spawn, stdout = process.stdout, timeouts = {} }) {
373
- workspace = checkedWorkspace(workspace);
427
+ if (workspace !== undefined) workspace = checkedWorkspace(workspace);
428
+ else if (init) fail('--init needs --workspace ABS naming the new or empty folder');
374
429
  commands = commands || Object.fromEntries(Object.entries(installed.executables).map(([name, file]) => [name, [file]]));
375
430
  const layout = stateLayout(path.resolve(stateRoot));
376
431
  await prepareState(layout);
377
432
  const base = cleanEnvironment(environment);
378
433
  const engineEnvironment = { ...base, WORK_STACK_RUNTIME: layout.workstackRuntime };
379
434
  const session = await withLock(layout.lock, async () => {
435
+ let remembered = null;
436
+ if (workspace === undefined) {
437
+ remembered = await rememberedWorkspace(layout);
438
+ workspace = checkedWorkspace(remembered.workspace);
439
+ }
380
440
  const uid = init ? await initializeWorkspace(commands, workspace, engineEnvironment) : await readWorkspaceUid(workspace);
441
+ if (remembered && uid !== remembered.uid) fail(`The workspace ${workspace} changed while opening. ${EXPLICIT}`);
381
442
  const backendId = await ensureBackendId(layout);
382
443
  const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl, timeout: timeouts.engine });
383
444
  const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl, timeout: timeouts.stackbite });
384
445
  await writeDockConfig(layout, stackbite.url);
446
+ // Only after the owner, StackBite and dock config are proven ready, and before the UI.
447
+ await rememberWorkspace(layout, workspace, uid);
385
448
  return { uid, backendId, engine, stackbite };
386
449
  });
387
450
  const summary = { schema: 'workbite.open.v1', workspace, workspace_uid: session.uid, endpoint: session.engine.endpoint,
@@ -399,4 +462,4 @@ async function openSuite({ installed, commands, workspace, init = false, stateRo
399
462
 
400
463
  module.exports = { cleanEnvironment, stateLayout, prepareState, ensureBackendId, workspaceKey, readWorkspaceUid,
401
464
  initializeWorkspace, engineIdentity, stackbiteIdentity, freePort, ensureEngine, ensureStackbite, writeDockConfig,
402
- withLock, uiEnvironment, openSuite, RESERVED_PORTS };
465
+ withLock, uiEnvironment, openSuite, rememberedWorkspace, RESERVED_PORTS, SELECTION_SCHEMA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workbite",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "WorkBite (Work Stack + StackBite) verified native suite installer and launcher",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "bin/",
21
21
  "lib/",
22
+ "skills/",
22
23
  "release.json",
23
24
  "README.md"
24
25
  ],
package/release.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "schema": "workbite.release.v1",
3
3
  "product": "WorkBite",
4
4
  "version": "0.1.0",
5
+ "launcherVersion": "0.1.2",
5
6
  "repository": "Shinick-Han/WorkBite-releases",
6
7
  "tag": "v0.1.0",
7
8
  "platforms": {