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/README.md CHANGED
@@ -6,8 +6,8 @@ WorkBite installs and opens **Work Stack** (organizes the work) and
6
6
  ```sh
7
7
  npm install -g workbite
8
8
  workbite install
9
- workbite open --workspace /abs/path/new-folder --init
10
- workbite open --workspace /abs/path/existing-workspace
9
+ workbite open --workspace /abs/path/new-folder --init # first time, once
10
+ workbite # afterwards
11
11
  ```
12
12
 
13
13
  Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
@@ -21,6 +21,11 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
21
21
  workspace's Work Stack owner and a StackBite backend (only processes it can
22
22
  prove it started), then runs the Work Stack terminal UI. Backends keep
23
23
  running after the UI exits.
24
+ - `workbite` (or `workbite open` without `--workspace`) reopens the last
25
+ workspace that opened successfully; an explicit `--workspace` always wins.
26
+ It refuses, starting nothing, if that workspace is missing, moved or its
27
+ `workspace.json` id changed, and never picks another folder by itself.
28
+ `--init` always needs an explicit `--workspace`.
24
29
  - Each backend's startup record (private token, launch id, port) is saved in
25
30
  the state folder *before* the process is started. A later `open` reuses
26
31
  that same attempt once it authenticates (also after a slow start), and
@@ -35,5 +40,21 @@ with absolute `WORKBITE_HOME` / `WORKBITE_STATE_DIR`) and never touches
35
40
  separately installed Work Stack or StackBite apps, their state or ports. It
36
41
  never approves provider trust or starts paid agents by itself.
37
42
 
43
+ Coding agents read and record Tasks of a workspace whose owner `workbite open`
44
+ already runs; nothing is started for them:
45
+
46
+ ```sh
47
+ workbite agent status # bound workspace path + UUID
48
+ workbite agent --workspace ABS --workspace-uid UUID context --task T-0001
49
+ workbite agent --workspace ABS --workspace-uid UUID tasks create --intent-id ID --input-file FILE
50
+ workbite mcp --workspace ABS --workspace-uid UUID # MCP stdio server
51
+ ```
52
+
53
+ Writes (`tasks create`, `tasks update`, `checkpoint`) always need the explicit
54
+ `--workspace` + `--workspace-uid` pair. `workbite mcp` binds one workspace when
55
+ it starts. The `workbite-native` agent skill shipped in this package
56
+ (`skills/workbite-native/`) describes the commands, packets and recovery
57
+ rules.
58
+
38
59
  This package is `UNLICENSED` (all rights reserved). Third-party licenses ship
39
60
  in each bundle's `licenses/` folder.
package/lib/agent.js ADDED
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+ // WorkBite agent interface: binds one already-running, WorkBite-owned Work Stack
3
+ // owner and forwards curated agent commands to it through the pinned native
4
+ // client's v1 `cli.execute` RPC. The native client is the only mapper to the
5
+ // owner's versioned APIs. Nothing here starts, downloads, initializes or adopts
6
+ // an owner, falls back to another workspace or tool, or replays a write.
7
+ const path = require('node:path');
8
+ const { spawn } = require('node:child_process');
9
+ const { randomBytes } = require('node:crypto');
10
+ const { fail, exists, readSmall, sha256, UUID } = require('./common');
11
+ const { cleanEnvironment, stateLayout, workspaceKey, readWorkspaceUid, rememberedWorkspace, engineIdentity } = require('./runtime');
12
+
13
+ const OWNER_SCHEMA = 'workbite.owner.v2';
14
+ const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
15
+ const LAUNCH = /^[0-9a-f]{16}$/;
16
+ const MAX_PACKET_BYTES = 32768;
17
+ const MAX_ARGV = 128;
18
+ const MAX_STDOUT = 1024 * 1024 + 8192;
19
+ const MAX_STDERR = 65536;
20
+ const DEFAULT_DEADLINE = 30000;
21
+ // Placeholder authority for the static capability table only; the client never contacts it for that command.
22
+ const NO_ENDPOINT = 'http://127.0.0.1:9';
23
+ const NO_UID = '00000000-0000-4000-8000-000000000000';
24
+
25
+ // Curated command paths; every other native command (raw rpc, legacy, deploy, delete, migration) is refused.
26
+ const AGENT_COMMANDS = Object.freeze(['capabilities', 'status', 'tasks list', 'tasks create', 'tasks update', 'context',
27
+ 'checkpoint', 'operation-status']);
28
+ const WRITE_COMMANDS = Object.freeze(['tasks create', 'tasks update', 'checkpoint']);
29
+ const FORBIDDEN_FLAGS = new Set(['--data-dir', '--endpoint', '--token-env', '--token', '--workspace', '--read-retries',
30
+ '--timeout-ms', '--request-id', '--generation', '--packet-file']);
31
+
32
+ const EXPLICIT = 'Name it with --workspace ABS --workspace-uid UUID, or open it once with: workbite open --workspace ABS';
33
+ const bindings = new WeakMap();
34
+
35
+ // Only the bound --workspace-uid (once, exactly that UID) may precede the command words.
36
+ function commandOf(argv, uid) {
37
+ if (!Array.isArray(argv) || argv[0] !== 'agent') return null;
38
+ let start = 1;
39
+ if (typeof uid === 'string' && uid) {
40
+ if (argv[1] === '--workspace-uid' && argv[2] === uid) start = 3;
41
+ else if (argv[1] === `--workspace-uid=${uid}`) start = 2;
42
+ }
43
+ const words = [];
44
+ for (const item of argv.slice(start)) { if (item.startsWith('-')) break; words.push(item); }
45
+ const name = words.join(' ');
46
+ return AGENT_COMMANDS.includes(name) ? name : null;
47
+ }
48
+ function checkedArgv(argv, uid) {
49
+ if (!Array.isArray(argv) || !argv.length || argv.length > MAX_ARGV
50
+ || argv.some(item => typeof item !== 'string' || item.includes('\0'))
51
+ || argv.reduce((total, item) => total + Buffer.byteLength(item), 0) > MAX_PACKET_BYTES) fail('Invalid WorkBite agent arguments');
52
+ const command = commandOf(argv, uid);
53
+ if (!command) fail('Unsupported WorkBite agent command; supported: ' + AGENT_COMMANDS.join(', '));
54
+ argv.forEach((item, index) => {
55
+ const key = item.split('=')[0];
56
+ if (FORBIDDEN_FLAGS.has(key)) fail(`WorkBite agent commands do not accept ${key}; authority comes from the binding`);
57
+ if (key === '--workspace-uid') {
58
+ const value = item.includes('=') ? item.slice(item.indexOf('=') + 1) : argv[index + 1];
59
+ if (value !== uid) fail('--workspace-uid does not match the bound WorkBite workspace');
60
+ }
61
+ });
62
+ return command;
63
+ }
64
+ function checkedStdin(stdin) {
65
+ if (stdin === undefined || stdin === null) return null;
66
+ if (typeof stdin !== 'string' || Buffer.byteLength(stdin) > MAX_PACKET_BYTES || stdin.includes('\0')) {
67
+ fail(`WorkBite agent input must be UTF-8 text of at most ${MAX_PACKET_BYTES} bytes`);
68
+ }
69
+ return stdin;
70
+ }
71
+ function clientCommand(installed, commands) {
72
+ const command = commands?.workstack_client || [installed?.executables?.workstack_client];
73
+ if (!Array.isArray(command) || typeof command[0] !== 'string' || !path.isAbsolute(command[0])) {
74
+ fail('The verified WorkBite native client is not available; run workbite check.');
75
+ }
76
+ return command;
77
+ }
78
+
79
+ function explicitWorkspace(workspace, workspaceUid) {
80
+ if ((workspace === undefined) !== (workspaceUid === undefined)) fail('--workspace and --workspace-uid must be given together');
81
+ if (typeof workspace !== 'string' || !path.isAbsolute(workspace) || /[\x00-\x1f]/.test(workspace)
82
+ || (process.platform === 'win32' && /^[\\/](?![\\/])/.test(workspace))) fail('--workspace must be an absolute path');
83
+ if (typeof workspaceUid !== 'string' || !UUID.test(workspaceUid)) fail('--workspace-uid must be a canonical workspace UUID');
84
+ return { workspace: path.resolve(workspace), uid: workspaceUid };
85
+ }
86
+ // Only the saved selection (written after a fully proven open) names a default; never a guess.
87
+ async function selectedWorkspace(layout) {
88
+ if (!await exists(layout.selection)) fail(`No WorkBite workspace is selected. ${EXPLICIT}`);
89
+ return rememberedWorkspace(layout);
90
+ }
91
+
92
+ async function ownerRecord(layout, workspace, uid) {
93
+ const file = path.join(layout.owners, `${workspaceKey(workspace)}.json`);
94
+ let bytes, record;
95
+ try { bytes = await readSmall(file, 65536); record = JSON.parse(bytes.toString('utf8')); } catch {
96
+ fail(`No ready WorkBite-owned Work Stack owner is recorded for ${workspace}. Start it with: workbite open --workspace ABS`);
97
+ }
98
+ const same = typeof record?.workspace === 'string' && path.isAbsolute(record.workspace)
99
+ && workspaceKey(path.resolve(record.workspace)) === workspaceKey(workspace);
100
+ if (record === null || typeof record !== 'object' || Array.isArray(record) || record.schema !== OWNER_SCHEMA
101
+ || record.state !== 'ready' || !same || record.workspace_uid !== uid || typeof record.launch !== 'string'
102
+ || !LAUNCH.test(record.launch) || typeof record.token !== 'string' || !/^[0-9a-f]{64}$/.test(record.token)
103
+ || typeof record.endpoint !== 'string' || !LOOPBACK.test(record.endpoint)) {
104
+ fail(`The Work Stack owner record for ${workspace} is not a ready WorkBite owner for that workspace UID. Open it with: workbite open --workspace ABS`);
105
+ }
106
+ return { file, digest: sha256(bytes), record };
107
+ }
108
+ async function proveOwner(layout, workspace, uid) {
109
+ if (await readWorkspaceUid(workspace) !== uid) fail(`The workspace.json id of ${workspace} does not match --workspace-uid`);
110
+ const owner = await ownerRecord(layout, workspace, uid);
111
+ if (!await engineIdentity(owner.record.endpoint, owner.record.token, uid)) {
112
+ fail(`The WorkBite Work Stack owner for ${workspace} could not be authenticated (stopped or replaced). Start it with: workbite open --workspace ABS`);
113
+ }
114
+ return owner;
115
+ }
116
+
117
+ async function bindAgent({ installed, stateRoot, environment = process.env, workspace, workspaceUid, commands, timeouts = {} } = {}) {
118
+ if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) fail('WorkBite state directory must be an absolute path');
119
+ const layout = stateLayout(path.resolve(stateRoot));
120
+ const command = clientCommand(installed, commands);
121
+ const chosen = workspace === undefined && workspaceUid === undefined
122
+ ? await selectedWorkspace(layout) : explicitWorkspace(workspace, workspaceUid);
123
+ const owner = await proveOwner(layout, chosen.workspace, chosen.uid);
124
+ const binding = Object.freeze({ workspace: chosen.workspace, workspaceUid: chosen.uid, endpoint: owner.record.endpoint,
125
+ stateRoot: layout.root });
126
+ bindings.set(binding, Object.freeze({ layout, command, environment: { ...environment }, digest: owner.digest,
127
+ launch: owner.record.launch, deadline: timeouts.agent || DEFAULT_DEADLINE }));
128
+ return binding;
129
+ }
130
+
131
+ // The bound owner must be the very same proven generation for every call.
132
+ async function recheck(binding, state) {
133
+ const owner = await proveOwner(state.layout, binding.workspace, binding.workspaceUid);
134
+ if (owner.digest !== state.digest || owner.record.launch !== state.launch || owner.record.endpoint !== binding.endpoint) {
135
+ fail('The bound WorkBite Work Stack owner changed since binding; bind again (nothing was sent).');
136
+ }
137
+ }
138
+
139
+ const boundedLine = text => String(text).replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 300);
140
+ // One request, one response, no retry. `write` decides how an uncertain outcome is reported.
141
+ function rpc(command, { endpoint, uid, environment, argv, stdin, deadline, write }) {
142
+ const requestId = `workbite-agent-${randomBytes(8).toString('hex')}`;
143
+ const request = JSON.stringify({ version: 1, request_id: requestId, workspace_uid: uid, generation: 0,
144
+ method: 'cli.execute', params: { argv, stdin } }) + '\n';
145
+ const nativeTimeout = Math.max(1000, Math.min(300000, deadline));
146
+ const [file, ...prefix] = command;
147
+ const uncertain = what => ({ code: 1, stdout: '', stderr: write
148
+ ? `error: commit unknown (${what}); WorkBite did not replay it. Check: workbite agent operation-status --intent-id ID\n`
149
+ : `error: ${what}; nothing was retried\n` });
150
+ return new Promise(resolve => {
151
+ let child;
152
+ try {
153
+ child = spawn(file, [...prefix, '--endpoint', endpoint, '--workspace-uid', uid, '--timeout-ms', String(nativeTimeout),
154
+ '--read-retries', '0', 'rpc'], { env: cleanEnvironment(environment), shell: false, windowsHide: true,
155
+ stdio: ['pipe', 'pipe', 'pipe'] });
156
+ } catch { resolve({ code: 1, stdout: '', stderr: 'error: cannot start the verified Work Stack native client\n' }); return; }
157
+ const out = [], err = [];
158
+ let outSize = 0, errSize = 0, done = false, overflow = false, started = false;
159
+ const finish = result => { if (!done) { done = true; clearTimeout(timer); resolve(result); } };
160
+ const timer = setTimeout(() => { child.kill(); finish(uncertain('native client deadline exceeded')); }, nativeTimeout + 5000);
161
+ child.stdout.on('data', chunk => {
162
+ outSize += chunk.length;
163
+ if (outSize > MAX_STDOUT) { overflow = true; child.kill(); } else out.push(chunk);
164
+ });
165
+ child.stderr.on('data', chunk => { errSize += chunk.length; if (errSize <= MAX_STDERR) err.push(chunk); });
166
+ child.stdin.on('error', () => {});
167
+ child.once('spawn', () => { started = true; });
168
+ child.once('error', () => finish(started ? uncertain('native client failed')
169
+ : { code: 1, stdout: '', stderr: 'error: cannot start the verified Work Stack native client\n' }));
170
+ child.once('close', () => {
171
+ if (overflow) { finish(uncertain('native response exceeded its bound')); return; }
172
+ const lines = Buffer.concat(out).toString('utf8').split('\n').filter(line => line.trim());
173
+ let response = null;
174
+ try { response = lines.length === 1 ? JSON.parse(lines[0]) : null; } catch { response = null; }
175
+ if (response === null || typeof response !== 'object' || response.version !== 1 || response.request_id !== requestId) {
176
+ finish(uncertain('no valid native response')); return;
177
+ }
178
+ if (response.error) {
179
+ const code = typeof response.error.code === 'string' ? response.error.code.replace(/[^A-Za-z]/g, '').slice(0, 32) : 'Error';
180
+ if (code === 'UnknownCommit') { finish(uncertain('owner response was lost')); return; }
181
+ finish({ code: 1, stdout: '', stderr: `error: ${code}: ${boundedLine(response.error.message || 'native request failed')}\n` });
182
+ return;
183
+ }
184
+ const result = response.result;
185
+ if (result === null || typeof result !== 'object' || !Number.isInteger(result.exit_code)
186
+ || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') { finish(uncertain('invalid native result')); return; }
187
+ finish({ code: result.exit_code, stdout: result.stdout, stderr: result.stderr });
188
+ });
189
+ child.stdin.end(request);
190
+ });
191
+ }
192
+
193
+ async function executeAgent(binding, argv, { stdin } = {}) {
194
+ const state = bindings.get(binding);
195
+ if (!state) fail('Not a WorkBite agent binding; use bindAgent');
196
+ const command = checkedArgv(argv, binding.workspaceUid);
197
+ const input = checkedStdin(stdin);
198
+ await recheck(binding, state);
199
+ return rpc(state.command, { endpoint: binding.endpoint, uid: binding.workspaceUid, environment: state.environment,
200
+ argv: [...argv], stdin: input, deadline: state.deadline, write: WRITE_COMMANDS.includes(command) });
201
+ }
202
+
203
+ // The static capability table needs no workspace authority; it never reaches an owner.
204
+ async function agentCapabilities({ installed, commands, environment = process.env, argv = ['agent', 'capabilities'], timeouts = {} } = {}) {
205
+ if (checkedArgv(argv, NO_UID) !== 'capabilities') fail('Only agent capabilities is authority-free');
206
+ return rpc(clientCommand(installed, commands), { endpoint: NO_ENDPOINT, uid: NO_UID, environment, argv: [...argv], stdin: null,
207
+ deadline: timeouts.agent || DEFAULT_DEADLINE, write: false });
208
+ }
209
+
210
+ module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, commandOf };
package/lib/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
  const path = require('node:path');
3
- const { fail } = require('./common');
3
+ const { fail, readSmall } = require('./common');
4
4
  const { locations, releaseSpec, ensureInstalled, stageReporter, packageVersion } = require('./install');
5
5
  const { openSuite } = require('./runtime');
6
+ const { bindAgent, executeAgent, agentCapabilities, WRITE_COMMANDS, MAX_PACKET_BYTES } = require('./agent');
6
7
 
7
8
  const HELP = `WorkBite ${packageVersion} - Work Stack organizes the work. StackBite gets it moving.
8
9
 
@@ -12,13 +13,32 @@ Usage:
12
13
  workbite open --workspace ABS [--init] [--no-ui]
13
14
  start or reuse this workspace's Work Stack owner and the
14
15
  StackBite backend, then run the Work Stack terminal UI
16
+ workbite [open] [--no-ui] reopen the last workspace WorkBite opened successfully
17
+ workbite agent [--workspace ABS --workspace-uid UUID] COMMAND [--input-file FILE | --stdin] [flags]
18
+ curated Work Stack agent commands against the running,
19
+ WorkBite-owned owner (never starts one): capabilities, status,
20
+ tasks list|create|update, context, checkpoint, operation-status
21
+ workbite mcp [--workspace ABS --workspace-uid UUID]
22
+ WorkBite MCP server on stdio for the same agent commands
15
23
  workbite --version | --help
16
24
 
25
+ agent:
26
+ Reads use the last workspace WorkBite opened unless --workspace and --workspace-uid name one.
27
+ Writes (tasks create, tasks update, checkpoint) always need --workspace ABS --workspace-uid UUID
28
+ and a packet from --input-file FILE or --stdin (UTF-8, at most 32768 bytes). An uncertain write
29
+ is never replayed; check it with agent operation-status --intent-id ID.
30
+
17
31
  open:
18
- --workspace ABS absolute workspace folder
19
- --init create a new Work Stack workspace in a new or empty folder
32
+ --workspace ABS absolute workspace folder; always wins over the remembered one
33
+ --init create a new Work Stack workspace in a new or empty folder (needs --workspace)
20
34
  --no-ui start or reuse the backends, print one JSON summary and exit
21
35
 
36
+ First time, name the workspace once:
37
+ workbite open --workspace /abs/path/new-folder --init
38
+ workbite open --workspace /abs/path/existing-workspace
39
+ Afterwards just run:
40
+ workbite
41
+
22
42
  The Work Stack owner and StackBite backend keep running after the UI exits, so
23
43
  agents continue. WorkBite never auto-updates, installs dependencies, approves
24
44
  provider trust or stops processes it cannot prove are its own.
@@ -28,12 +48,98 @@ Locations (override with absolute WORKBITE_HOME / WORKBITE_STATE_DIR):
28
48
  Linux $XDG_DATA_HOME/workbite/app/<version> $XDG_STATE_HOME/workbite
29
49
  `;
30
50
 
51
+ // Native flags each curated agent command accepts (value-taking unless listed in FLAGS_WITHOUT_VALUE).
52
+ const AGENT_FLAGS = Object.freeze({
53
+ capabilities: ['--command', '--section'],
54
+ status: ['--workspace-revision'],
55
+ 'tasks list': ['--status', '--limit', '--offset'],
56
+ 'tasks create': ['--intent-id'],
57
+ 'tasks update': ['--intent-id', '--task', '--expected-revision'],
58
+ context: ['--task', '--view', '--format', '--source', '--fingerprint', '--if-fingerprint'],
59
+ checkpoint: ['--intent-id'],
60
+ 'operation-status': ['--intent-id'],
61
+ });
62
+ const FLAGS_WITHOUT_VALUE = new Set(['--workspace-revision', '--fingerprint']);
63
+ const REPEATABLE = new Set(['--source']);
64
+ const PACKET_COMMANDS = new Set(['tasks create', 'tasks update', 'checkpoint']);
65
+
66
+ function parseBinding(options, argument, next) {
67
+ if (argument === '--workspace' && options.workspace === undefined && next !== undefined && !next.startsWith('--')) {
68
+ if (!path.isAbsolute(next)) fail('--workspace must be an absolute path');
69
+ options.workspace = next; return true;
70
+ }
71
+ if (argument === '--workspace-uid' && options.workspaceUid === undefined && next !== undefined && !next.startsWith('--')) {
72
+ options.workspaceUid = next; return true;
73
+ }
74
+ return false;
75
+ }
76
+ function pairedBinding(options) {
77
+ if ((options.workspace === undefined) !== (options.workspaceUid === undefined)) {
78
+ fail('--workspace and --workspace-uid must be given together');
79
+ }
80
+ }
81
+
82
+ function parseAgent(rest) {
83
+ // The binding pair may come before the command words, after them, or split around them;
84
+ // each flag is still accepted once, with its value, and nothing else may precede the words.
85
+ const options = { command: 'agent' };
86
+ while (rest.length && parseBinding(options, rest[0], rest[1])) rest.splice(0, 2);
87
+ if (rest.length && rest[0].startsWith('-')) {
88
+ fail(`Unknown or repeated agent option before the command: ${String(rest[0]).slice(0, 64)}. See workbite --help.`);
89
+ }
90
+ const words = [];
91
+ while (rest.length && !rest[0].startsWith('-')) words.push(rest.shift());
92
+ const name = words.join(' ');
93
+ if (!Object.hasOwn(AGENT_FLAGS, name)) {
94
+ fail(`Unknown agent command: ${name.slice(0, 64) || '(none)'}. Supported: ${Object.keys(AGENT_FLAGS).join(', ')}.`);
95
+ }
96
+ Object.assign(options, { name, native: ['agent', ...words] });
97
+ for (let index = 0; index < rest.length; index++) {
98
+ const argument = rest[index], next = rest[index + 1];
99
+ if (parseBinding(options, argument, next)) { index++; continue; }
100
+ if (argument === '--input-file' && options.inputFile === undefined && !options.stdin && next !== undefined
101
+ && !next.startsWith('--')) { options.inputFile = next; index++; continue; }
102
+ if (argument === '--stdin' && !options.stdin && options.inputFile === undefined) { options.stdin = true; continue; }
103
+ if (!AGENT_FLAGS[name].includes(argument) || (!REPEATABLE.has(argument) && options.native.includes(argument))) {
104
+ fail(`Unknown or repeated agent ${name} option: ${String(argument).slice(0, 64)}. See workbite --help.`);
105
+ }
106
+ options.native.push(argument);
107
+ if (FLAGS_WITHOUT_VALUE.has(argument)) continue;
108
+ if (next === undefined || next.startsWith('--')) fail(`${argument} needs a value`);
109
+ options.native.push(next); index++;
110
+ }
111
+ pairedBinding(options);
112
+ const packet = options.stdin || options.inputFile !== undefined;
113
+ if (PACKET_COMMANDS.has(name) !== packet) {
114
+ fail(PACKET_COMMANDS.has(name) ? `agent ${name} needs a packet from --input-file FILE or --stdin`
115
+ : `agent ${name} does not take --input-file or --stdin`);
116
+ }
117
+ if (WRITE_COMMANDS.includes(name) && options.workspace === undefined) {
118
+ fail(`agent ${name} changes Work Stack; name the workspace with --workspace ABS --workspace-uid UUID`);
119
+ }
120
+ if (packet) options.native.push('--stdin');
121
+ return options;
122
+ }
123
+ function parseMcp(rest) {
124
+ const options = { command: 'mcp' };
125
+ for (let index = 0; index < rest.length; index++) {
126
+ if (!parseBinding(options, rest[index], rest[index + 1])) {
127
+ fail(`Unknown or repeated mcp option: ${String(rest[index]).slice(0, 64)}. See workbite --help.`);
128
+ }
129
+ index++;
130
+ }
131
+ pairedBinding(options);
132
+ return options;
133
+ }
134
+
31
135
  function parseArguments(argv) {
32
136
  if (argv.length === 1 && ['--help', '-h', 'help'].includes(argv[0])) return { command: 'help' };
33
- if (!argv.length) return { command: 'help' };
34
137
  if (argv.length === 1 && ['--version', '-v', 'version'].includes(argv[0])) return { command: 'version' };
35
- const [command, ...rest] = argv;
138
+ // Bare `workbite` (or only open options) means `workbite open`.
139
+ const [command, ...rest] = argv.length && !argv[0].startsWith('--') ? argv : ['open', ...argv];
36
140
  if ((command === 'install' || command === 'check') && !rest.length) return { command };
141
+ if (command === 'agent') return parseAgent([...rest]);
142
+ if (command === 'mcp') return parseMcp(rest);
37
143
  if (command === 'open') {
38
144
  const options = { command, init: false, ui: true };
39
145
  for (let index = 0; index < rest.length; index++) {
@@ -44,13 +150,68 @@ function parseArguments(argv) {
44
150
  && !rest[index + 1].startsWith('--')) options.workspace = rest[++index];
45
151
  else fail(`Unknown or repeated open option: ${String(argument).slice(0, 64)}. See workbite --help.`);
46
152
  }
47
- if (options.workspace === undefined) fail('workbite open needs --workspace ABS');
153
+ // Without --workspace the remembered workspace is reopened; --init never picks a folder by itself.
154
+ if (options.workspace === undefined) {
155
+ if (options.init) fail('--init needs --workspace ABS naming the new or empty folder');
156
+ return options;
157
+ }
48
158
  if (!path.isAbsolute(options.workspace)) fail('--workspace must be an absolute path');
49
159
  return options;
50
160
  }
51
161
  fail(`Unknown command: ${String(command).slice(0, 64)}. See workbite --help.`);
52
162
  }
53
163
 
164
+ // A packet is bounded UTF-8 text handed to the native client as stdin; it never enters argv.
165
+ function packetText(bytes) {
166
+ if (bytes.length > MAX_PACKET_BYTES) fail(`The agent packet exceeds ${MAX_PACKET_BYTES} bytes`);
167
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { fail('The agent packet is not valid UTF-8'); }
168
+ }
169
+ async function readPacket(parsed, stdin) {
170
+ if (parsed.inputFile !== undefined) {
171
+ let bytes;
172
+ try { bytes = await readSmall(path.resolve(parsed.inputFile), MAX_PACKET_BYTES); } catch (error) {
173
+ fail(/oversized/.test(error.message) ? `The agent packet file is not a regular file of at most ${MAX_PACKET_BYTES} bytes`
174
+ : 'The agent packet file could not be read');
175
+ }
176
+ return packetText(bytes);
177
+ }
178
+ if (!parsed.stdin) return null;
179
+ const chunks = [];
180
+ let size = 0;
181
+ for await (const chunk of stdin) {
182
+ size += chunk.length;
183
+ if (size > MAX_PACKET_BYTES) fail(`The agent packet exceeds ${MAX_PACKET_BYTES} bytes`);
184
+ chunks.push(Buffer.from(chunk));
185
+ }
186
+ return packetText(Buffer.concat(chunks));
187
+ }
188
+
189
+ async function runAgent(parsed, { installed, stateRoot, environment, commands, timeouts, stdout, stderr, stdin }) {
190
+ let result, binding = null;
191
+ if (parsed.name === 'capabilities' && parsed.workspace === undefined) {
192
+ result = await agentCapabilities({ installed, commands, environment, argv: parsed.native, timeouts });
193
+ } else {
194
+ binding = await bindAgent({ installed, stateRoot, environment, workspace: parsed.workspace,
195
+ workspaceUid: parsed.workspaceUid, commands, timeouts });
196
+ const packet = await readPacket(parsed, stdin || process.stdin);
197
+ result = await executeAgent(binding, parsed.native, { stdin: packet });
198
+ }
199
+ let output = result.stdout;
200
+ if (parsed.name === 'status' && result.code === 0) {
201
+ try {
202
+ const document = JSON.parse(output);
203
+ if (document !== null && typeof document === 'object' && !Array.isArray(document)) {
204
+ document.workbite = { schema: 'workbite.agent.binding.v1', workspace: binding.workspace,
205
+ workspace_uid: binding.workspaceUid, endpoint: binding.endpoint, state: binding.stateRoot };
206
+ output = JSON.stringify(document) + '\n';
207
+ }
208
+ } catch { /* not JSON: passed through unchanged */ }
209
+ }
210
+ if (output) stdout.write(output);
211
+ if (result.stderr) stderr.write(result.stderr);
212
+ return { code: result.code, signal: null };
213
+ }
214
+
54
215
  async function main(argv = process.argv.slice(2), options = {}) {
55
216
  const stdout = options.stdout || process.stdout, stderr = options.stderr || process.stderr;
56
217
  const parsed = parseArguments(argv);
@@ -60,15 +221,23 @@ async function main(argv = process.argv.slice(2), options = {}) {
60
221
  const platform = options.platform || process.platform;
61
222
  const spec = releaseSpec(options.manifest || require('../release.json'), platform, options.arch || process.arch, options);
62
223
  const where = locations(environment, platform);
224
+ // agent and mcp only use an already installed, verified suite: nothing is downloaded or started.
63
225
  const installed = await ensureInstalled(spec, { ...options, apps: options.apps || where.apps,
64
- checkOnly: parsed.command === 'check', progress: options.progress || stageReporter(stderr) });
226
+ checkOnly: ['check', 'agent', 'mcp'].includes(parsed.command), progress: options.progress || stageReporter(stderr) });
227
+ const stateRoot = options.stateRoot || where.state;
228
+ if (parsed.command === 'agent') return runAgent(parsed, { ...options, installed, stateRoot, environment, stdout, stderr });
229
+ if (parsed.command === 'mcp') {
230
+ return require('./mcp').runMcp({ installed, stateRoot, environment, workspace: parsed.workspace,
231
+ workspaceUid: parsed.workspaceUid, commands: options.commands, timeouts: options.timeouts,
232
+ stdin: options.stdin || process.stdin, stdout, stderr });
233
+ }
65
234
  if (parsed.command === 'install' || parsed.command === 'check') {
66
235
  stdout.write(JSON.stringify({ schema: 'workbite.install.v1', version: spec.version, target: spec.target,
67
- home: installed.home, state: options.stateRoot || where.state }) + '\n');
236
+ home: installed.home, state: stateRoot }) + '\n');
68
237
  return { code: 0, signal: null };
69
238
  }
70
239
  return openSuite({ installed, commands: options.commands, workspace: parsed.workspace, init: parsed.init, ui: parsed.ui,
71
- stateRoot: options.stateRoot || where.state, environment, spawnImpl: options.spawnImpl, stdout, timeouts: options.timeouts });
240
+ stateRoot, environment, spawnImpl: options.spawnImpl, stdout, timeouts: options.timeouts });
72
241
  }
73
242
 
74
243
  module.exports = { main, parseArguments, HELP };
package/lib/install.js CHANGED
@@ -49,16 +49,25 @@ function platformTarget(platform = process.platform, arch = process.arch, runtim
49
49
  return target;
50
50
  }
51
51
 
52
- function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}) {
52
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
53
+ function canonicalVersion(value) { return typeof value === 'string' && SEMVER.test(value); }
54
+
55
+ // manifest.version names the sealed native artifact (asset, tag, URL, bundle, cache directory).
56
+ // An explicit launcherVersion lets a launcher-only npm patch reuse that artifact unchanged;
57
+ // without it the legacy binding (artifact version == package version) still applies.
58
+ function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}, launcher = packageVersion) {
53
59
  const target = platformTarget(platform, arch, runtime);
54
- if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || manifest.version !== packageVersion
60
+ const bound = Object.hasOwn(manifest, 'launcherVersion')
61
+ ? canonicalVersion(manifest.launcherVersion) && manifest.launcherVersion === launcher
62
+ : manifest.version === launcher;
63
+ if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || !canonicalVersion(manifest.version) || !bound
55
64
  || manifest.repository !== REPOSITORY || manifest.tag !== `v${manifest.version}`) fail('Invalid pinned WorkBite release identity');
56
65
  const spec = manifest.platforms?.[target];
57
66
  const asset = `workbite-${manifest.version}-${target}${platform === 'win32' ? '.zip' : '.tar.gz'}`;
58
67
  if (!spec || spec.asset !== asset
59
68
  || spec.url !== `https://github.com/${manifest.repository}/releases/download/${manifest.tag}/${asset}`) fail('Invalid pinned release asset');
60
69
  if (spec.sha256 === null || spec.size === null || spec.bundleSha256 === null) {
61
- fail(`This workbite ${manifest.version} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
70
+ fail(`This workbite ${launcher} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
62
71
  }
63
72
  if (![spec.sha256, spec.bundleSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
64
73
  || !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');