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 +23 -2
- package/lib/agent.js +210 -0
- package/lib/cli.js +178 -9
- package/lib/install.js +12 -3
- package/lib/mcp.js +501 -0
- package/lib/runtime.js +66 -3
- package/package.json +2 -1
- package/release.json +1 -0
- package/skills/workbite-native/SKILL.md +83 -0
- package/skills/workbite-native/agents/openai.yaml +4 -0
- package/skills/workbite-native/references/commands.md +199 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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');
|