workbite 0.1.12 → 0.1.14

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
@@ -3,8 +3,11 @@
3
3
  WorkBite installs and opens **Work Stack** (organizes the work) and
4
4
  **StackBite** (gets it moving) as one native suite.
5
5
 
6
- Launcher 0.1.11 (native bundle 0.1.11) supports **Windows x64 and Linux x64**
7
- (glibc 2.28+). It opens the familiar TypeScript Work Stack UI by default, which
6
+ Launcher 0.1.14 pins the **Windows x64** native bundle and is published under
7
+ the `windows` dist-tag. Install it with `npm install -g workbite@0.1.14`.
8
+ The `latest` dist-tag retains the earlier Windows/Linux x64 (glibc 2.28+)
9
+ release; no new Linux binary is included in 0.1.14.
10
+ It opens the familiar TypeScript Work Stack UI by default, which
8
11
  has one Tasks workbench: switch Focus/Table/Board without losing your selected
9
12
  Task, deploy its agent, and control the native terminal beside or below the Tasks.
10
13
  Use F6 to move focus, Ctrl+B to hide the sidebar, Alt+Shift+L to switch layouts,
@@ -24,7 +27,7 @@ operator handover of existing backend processes; the launcher does not stop
24
27
  them or clear startup records automatically.
25
28
 
26
29
  ```sh
27
- npm install -g workbite
30
+ npm install -g workbite@0.1.14 # Windows x64
28
31
  workbite install
29
32
  workbite open --workspace /abs/path/new-folder --init # first time, once
30
33
  workbite # afterwards (TypeScript UI)
@@ -37,7 +40,8 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
37
40
  `github.com/Shinick-Han/WorkBite-releases`, verifies its size and SHA-256 and
38
41
  every file listed in its `bundle.json`, and installs it into an immutable
39
42
  per-user version folder. No auto-update, no install scripts, no dependencies.
40
- - `workbite check` re-verifies the installed bundle without downloading.
43
+ - `workbite check` re-verifies installed bundle integrity without downloading;
44
+ it does not test backend health, workspace admission or UI behavior.
41
45
  - `workbite open --workspace ABS [--init] [--no-ui] [--frontend typescript|rust]`
42
46
  starts or reuses the workspace's Work Stack owner and a StackBite backend
43
47
  (only processes it can prove it started), then runs the TypeScript Work Stack
@@ -52,10 +56,13 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
52
56
  - Each backend's startup record (private token, launch id, port) is saved in
53
57
  the state folder *before* the process is started. A later `open` reuses
54
58
  that same attempt once it authenticates (also after a slow start), and
55
- otherwise refuses: an unreadable, invalid or unreachable record is never
56
- cleared, replaced or followed by a second backend, and nothing is stopped.
57
- If a refusal names a record and you have confirmed no WorkBite process for
58
- that state is running, move that record aside and open again.
59
+ otherwise refuses. Unreadable/invalid records remain untouched; an
60
+ unreachable process is not assumed stopped. The unreleased source's narrow
61
+ archival path requires trusted same-host previous-boot evidence for eligible
62
+ owner/backend records. It does not resolve foreign hosts or native intents.
63
+ Diagnose the exact host/boot, workspace, backend and native intent before
64
+ operator handover. Age or a missing local PID cannot establish that a process
65
+ on another NFS client has stopped; preserve uncertain records.
59
66
 
60
67
  WorkBite keeps its own state (`%LOCALAPPDATA%\WorkBite` on Windows,
61
68
  `~/.local/share/workbite` and `~/.local/state/workbite` on Linux; override
@@ -63,6 +70,25 @@ with absolute `WORKBITE_HOME` / `WORKBITE_STATE_DIR`) and never touches
63
70
  separately installed Work Stack or StackBite apps, their state or ports. It
64
71
  never approves provider trust or starts paid agents by itself.
65
72
 
73
+ Startup diagnostics include `workbite status [--json]`,
74
+ `workbite doctor [--json]` and `workbite recover --dry-run [--json]`. They inspect evidence and plan
75
+ only: no process starts/stops, record reset, native intent repair or migration.
76
+ `open --no-ui` does start/reuse the backend stack (including native selection for
77
+ v2) and prints JSON, but never launches or validates the foreground UI. Startup
78
+ stage messages distinguish that UI launch from backend readiness; the launcher
79
+ continues waiting while the UI runs. Full installed-file hashing still occurs
80
+ on reuse, so these changes do not promise faster opens.
81
+
82
+ For NFS shared homes, configure the existing absolute `WORKBITE_STATE_DIR`
83
+ override to a distinct host-local runtime directory per host after coordinating
84
+ any existing owners and active Seats. This does not move old state or permit
85
+ parallel writers to one shared workspace. `WORKBITE_HOME` separately overrides
86
+ the installation root (`app/` beneath it); changing it is not a universal tar
87
+ fix. See the source repository's
88
+ [startup troubleshooting guide](../../docs/STARTUP-TROUBLESHOOTING.md) for tcsh
89
+ `setenv` examples, failure diagnosis, bounded tar stderr and safe handover
90
+ requirements. Keep workspace locks and unknown native intents intact.
91
+
66
92
  Coding agents read and record Tasks of a workspace whose owner `workbite open`
67
93
  already runs; nothing is started for them:
68
94
 
@@ -79,5 +105,15 @@ it starts. The `workbite-native` agent skill shipped in this package
79
105
  (`skills/workbite-native/`) describes the commands, packets and recovery
80
106
  rules.
81
107
 
108
+ Windows 0.1.14 also includes a read-only phone companion pilot. Open Settings
109
+ with `,`, select Phone, enable it, then pair using the expiring QR and compare
110
+ the complete fingerprint before approving on the PC. Remembered browsers can
111
+ reconnect; devices can be revoked. Initial PC enrollment requires an operator
112
+ invitation through the supported embedding/bootstrap surface; this release
113
+ does not include a public invitation-entry UI. The hosted relay needs no user
114
+ domain or Cloudflare client. CurrentUser DPAPI on local NTFS is required.
115
+ Mobile Task writes, deployment, terminal input, replies, notifications and
116
+ background connectivity are not supported. The PC must remain online.
117
+
82
118
  This package is `UNLICENSED` (all rights reserved). Third-party licenses ship
83
119
  in each bundle's `licenses/` folder.
package/lib/agent.js CHANGED
@@ -14,6 +14,7 @@ const OWNER_SCHEMA = 'workbite.owner.v2';
14
14
  const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
15
15
  const LAUNCH = /^[0-9a-f]{16}$/;
16
16
  const MAX_PACKET_BYTES = 32768;
17
+ const MAX_TRANSFER_BYTES = 1024 * 1024;
17
18
  const MAX_ARGV = 128;
18
19
  const MAX_STDOUT = 1024 * 1024 + 8192;
19
20
  const MAX_STDERR = 65536;
@@ -24,8 +25,8 @@ const NO_UID = '00000000-0000-4000-8000-000000000000';
24
25
 
25
26
  // Curated command paths; every other native command (raw rpc, legacy, deploy, delete, migration) is refused.
26
27
  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']);
28
+ 'checkpoint', 'operation-status', 'export', 'import', 'import-status', 'import-rollback']);
29
+ const WRITE_COMMANDS = Object.freeze(['tasks create', 'tasks update', 'checkpoint', 'import', 'import-rollback']);
29
30
  const FORBIDDEN_FLAGS = new Set(['--data-dir', '--endpoint', '--token-env', '--token', '--workspace', '--read-retries',
30
31
  '--timeout-ms', '--request-id', '--generation', '--packet-file']);
31
32
 
@@ -61,13 +62,28 @@ function checkedArgv(argv, uid) {
61
62
  });
62
63
  return command;
63
64
  }
64
- function checkedStdin(stdin) {
65
+ function checkedStdin(stdin, command) {
65
66
  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`);
67
+ const maximum = command === 'import' ? MAX_TRANSFER_BYTES : MAX_PACKET_BYTES;
68
+ if (typeof stdin !== 'string' || Buffer.byteLength(stdin) > maximum || stdin.includes('\0') || stdin.isWellFormed?.() === false) {
69
+ fail(`WorkBite agent input must be UTF-8 text of at most ${maximum} bytes`);
68
70
  }
69
71
  return stdin;
70
72
  }
73
+ // Capture recovery identity before launch; never reconstruct it from native error text.
74
+ function checkedIntent(argv, command, write) {
75
+ const flags = argv.filter(item => item.split('=')[0] === '--intent-id');
76
+ if (!flags.length && !write) return null;
77
+ if (flags.length !== 1 || (!WRITE_COMMANDS.includes(command)
78
+ && !['operation-status', 'import-status'].includes(command)) || argv.includes('--')) {
79
+ fail('Invalid or repeated --intent-id');
80
+ }
81
+ const flag = flags[0];
82
+ const value = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : argv[argv.indexOf(flag) + 1];
83
+ if (typeof value !== 'string' || (!flag.includes('=') && value.startsWith('--'))
84
+ || value.length < 8 || value.length > 128 || /[^A-Za-z0-9._:-]/.test(value)) fail('Invalid --intent-id');
85
+ return value;
86
+ }
71
87
  function clientCommand(installed, commands) {
72
88
  const command = commands?.workstack_client || [installed?.executables?.workstack_client];
73
89
  if (!Array.isArray(command) || typeof command[0] !== 'string' || !path.isAbsolute(command[0])) {
@@ -136,17 +152,27 @@ async function recheck(binding, state) {
136
152
  }
137
153
  }
138
154
 
139
- const boundedLine = text => String(text).replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 300);
155
+ // JSON quoting retains the exact path without letting terminal controls become diagnostics.
156
+ const diagnosticJson = value => JSON.stringify(value).replace(/[\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g,
157
+ character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
140
158
  // 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 }) {
159
+ function rpc(command, { endpoint, uid, workspace, environment, argv, stdin, deadline, write, intent, name }) {
142
160
  const requestId = `workbite-agent-${randomBytes(8).toString('hex')}`;
143
161
  const request = JSON.stringify({ version: 1, request_id: requestId, workspace_uid: uid, generation: 0,
144
162
  method: 'cli.execute', params: { argv, stdin } }) + '\n';
145
163
  const nativeTimeout = Math.max(1000, Math.min(300000, deadline));
146
164
  const [file, ...prefix] = command;
165
+ // cli.execute wraps escaped stdout in JSON; a bounded archive may expand at both layers.
166
+ const transfer = ['export', 'import', 'import-status', 'import-rollback'].includes(name);
167
+ const stdoutLimit = transfer ? 8 * MAX_TRANSFER_BYTES : MAX_STDOUT;
168
+ const lookup = transfer ? 'import-status' : 'operation-status';
169
+ const intentFlag = intent?.startsWith('--') ? `--intent-id=${intent}` : `--intent-id ${intent}`;
147
170
  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` });
171
+ ? `error: commit unknown (${what}); WorkBite did not replay it. Check: workbite agent ${lookup} ${intentFlag}\n`
172
+ + `Use the same bound workspace: ${diagnosticJson({ workspace, workspace_uid: uid })}\n`
173
+ : `error: ${what}; nothing was retried\n`,
174
+ ...(write ? { meta: { command: `agent.${name.replace(/ /g, '.')}`, intent_id: intent, commit_state: 'unknown' },
175
+ workbite: { schema: 'workbite.agent.binding.v1', workspace, workspace_uid: uid } } : {}) });
150
176
  return new Promise(resolve => {
151
177
  let child;
152
178
  try {
@@ -160,7 +186,7 @@ function rpc(command, { endpoint, uid, environment, argv, stdin, deadline, write
160
186
  const timer = setTimeout(() => { child.kill(); finish(uncertain('native client deadline exceeded')); }, nativeTimeout + 5000);
161
187
  child.stdout.on('data', chunk => {
162
188
  outSize += chunk.length;
163
- if (outSize > MAX_STDOUT) { overflow = true; child.kill(); } else out.push(chunk);
189
+ if (outSize > stdoutLimit) { overflow = true; child.kill(); } else out.push(chunk);
164
190
  });
165
191
  child.stderr.on('data', chunk => { errSize += chunk.length; if (errSize <= MAX_STDERR) err.push(chunk); });
166
192
  child.stdin.on('error', () => {});
@@ -176,9 +202,10 @@ function rpc(command, { endpoint, uid, environment, argv, stdin, deadline, write
176
202
  finish(uncertain('no valid native response')); return;
177
203
  }
178
204
  if (response.error) {
179
- const code = typeof response.error.code === 'string' ? response.error.code.replace(/[^A-Za-z]/g, '').slice(0, 32) : 'Error';
205
+ const code = ['InvalidRequest', 'UnsupportedVersion', 'Unavailable', 'Timeout', 'Conflict', 'Unauthorized', 'Io', 'UnknownCommit']
206
+ .includes(response.error.code) ? response.error.code : 'Error';
180
207
  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` });
208
+ finish({ code: 1, stdout: '', stderr: `error: ${code}: native request failed\n` });
182
209
  return;
183
210
  }
184
211
  const result = response.result;
@@ -194,17 +221,20 @@ async function executeAgent(binding, argv, { stdin } = {}) {
194
221
  const state = bindings.get(binding);
195
222
  if (!state) fail('Not a WorkBite agent binding; use bindAgent');
196
223
  const command = checkedArgv(argv, binding.workspaceUid);
197
- const input = checkedStdin(stdin);
224
+ const args = [...argv];
225
+ const input = checkedStdin(stdin, command);
226
+ const write = WRITE_COMMANDS.includes(command) && !(command === 'import' && args.includes('--dry-run'));
227
+ const intent = checkedIntent(args, command, write);
198
228
  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) });
229
+ return rpc(state.command, { endpoint: binding.endpoint, uid: binding.workspaceUid, workspace: binding.workspace,
230
+ environment: state.environment, argv: args, stdin: input, deadline: state.deadline, write, intent, name: command });
201
231
  }
202
232
 
203
233
  // The static capability table needs no workspace authority; it never reaches an owner.
204
234
  async function agentCapabilities({ installed, commands, environment = process.env, argv = ['agent', 'capabilities'], timeouts = {} } = {}) {
205
235
  if (checkedArgv(argv, NO_UID) !== 'capabilities') fail('Only agent capabilities is authority-free');
206
236
  return rpc(clientCommand(installed, commands), { endpoint: NO_ENDPOINT, uid: NO_UID, environment, argv: [...argv], stdin: null,
207
- deadline: timeouts.agent || DEFAULT_DEADLINE, write: false });
237
+ deadline: timeouts.agent || DEFAULT_DEADLINE, write: false, name: 'capabilities' });
208
238
  }
209
239
 
210
- module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, commandOf };
240
+ module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, MAX_TRANSFER_BYTES, commandOf };
package/lib/archive.js CHANGED
@@ -9,6 +9,7 @@ const { Transform, Writable } = require('node:stream');
9
9
  const { pipeline } = require('node:stream/promises');
10
10
  const { spawn } = require('node:child_process');
11
11
  const path = require('node:path');
12
+ const { stripVTControlCharacters } = require('node:util');
12
13
  const { LIMITS, fail, memberPath } = require('./common');
13
14
  const utf8 = new TextDecoder('utf-8', { fatal: true });
14
15
  const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
@@ -213,21 +214,121 @@ async function boundedGunzip(archive, destination) {
213
214
  function nativeTar() {
214
215
  return process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : '/usr/bin/tar';
215
216
  }
216
- async function extractArchive(archive, destination, spec, { spawnImpl = spawn } = {}) {
217
+ // Diagnostics are untrusted subprocess output or paths. Never echo URLs, auth
218
+ // headers, credential assignments or terminal control sequences back to a terminal.
219
+ function diagnosticText(value, maximum = 4096) {
220
+ return stripVTControlCharacters(String(value))
221
+ .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
222
+ .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted URL]')
223
+ .replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*/gi, '[redacted credentials]')
224
+ .replace(/\b(?:bearer|basic)\s+\S+/gi, '[redacted credentials]')
225
+ .replace(/\b([\w.-]*(?:token|password|passwd|secret|credential|api[_-]?key)[\w.-]*)\s*[:=]\s*(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^\s]*)/gi, '$1=[redacted]')
226
+ .slice(0, maximum);
227
+ }
228
+ function extractionError(reason, destination, { code = null, signal = null, stderr = '', truncated = false, spawnCode } = {}) {
229
+ const detail = diagnosticText(stderr);
230
+ const permission = /permission denied|operation not permitted|read-only file system|access is denied/i.test(detail)
231
+ || ['EACCES', 'EPERM', 'EROFS'].includes(spawnCode);
232
+ const storage = /no space left|disk (?:is )?full|quota exceeded|stale file handle|input\/output error/i.test(detail)
233
+ || ['ENOSPC', 'EDQUOT', 'ESTALE', 'EIO'].includes(spawnCode);
234
+ const hint = permission ? ' Check destination permissions, mount options and NFS access.'
235
+ : storage ? ' Check free space, quota and filesystem/NFS health.' : '';
236
+ const error = new Error(`${reason} (exit code: ${code ?? 'none'}, signal: ${diagnosticText(signal ?? 'none', 64)}${spawnCode ? `, spawn: ${diagnosticText(spawnCode, 64)}` : ''}); destination: ${diagnosticText(path.resolve(destination))}.${hint}${detail ? ` stderr: ${detail}${truncated ? ' [truncated]' : ''}` : ''}`);
237
+ error.exitCode = code;
238
+ error.signal = signal;
239
+ if (spawnCode) error.code = spawnCode;
240
+ return error;
241
+ }
242
+ async function extractArchive(archive, destination, spec, { spawnImpl = spawn, timeout = LIMITS.timeout } = {}) {
217
243
  let input = archive;
218
244
  const windows = spec.asset.endsWith('.zip');
219
245
  if (!windows) { input = archive + '.tar'; await boundedGunzip(archive, input); }
220
246
  const entries = windows ? await zipEntries(input) : await tarEntries(input);
221
247
  validateEntries(entries, windows);
222
248
  await new Promise((resolve, reject) => {
223
- const child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
224
- { shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
225
- let errorBytes = 0;
226
- const timer = setTimeout(() => { child.kill(); reject(new Error('Native tar extraction timed out')); }, LIMITS.timeout);
227
- child.stderr.on('data', chunk => { errorBytes += chunk.length; if (errorBytes > 65536) { child.kill(); } });
228
- child.once('error', () => { clearTimeout(timer); reject(new Error('Native tar is unavailable; install the operating system tar command')); });
229
- child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('Native tar rejected the release archive')); });
249
+ let child, timer, closeTimer, errorBytes = 0, captured = 0, stopped, processCode;
250
+ let started = false, settled = false;
251
+ const chunks = [];
252
+ const details = (code, signal, spawnCode) => ({ code, signal, spawnCode,
253
+ stderr: Buffer.concat(chunks).toString('utf8'), truncated: errorBytes > 4096 });
254
+ const finish = (error, unknown = false) => {
255
+ if (settled) return;
256
+ settled = true;
257
+ clearTimeout(timer);
258
+ clearTimeout(closeTimer);
259
+ if (child) {
260
+ child.removeListener('spawn', onSpawn);
261
+ child.removeListener('error', onError);
262
+ child.removeListener('close', onClose);
263
+ child.stderr.removeListener('data', onData);
264
+ // A late kill error must remain handled without retaining this attempt's
265
+ // callbacks or changing its already settled cleanup authority.
266
+ child.on('error', () => {});
267
+ if (unknown) { child.unref?.(); child.stderr.destroy(); }
268
+ child = undefined;
269
+ }
270
+ chunks.length = 0;
271
+ if (error) reject(error);
272
+ else resolve();
273
+ };
274
+ const spawnFailure = error => {
275
+ const failure = extractionError(error.code === 'ENOENT'
276
+ ? 'Native tar is unavailable; install the operating system tar command'
277
+ : 'Native tar could not start', destination, details(null, null, error.code));
278
+ finish(failure);
279
+ };
280
+ const stop = reason => {
281
+ if (settled || stopped) return;
282
+ stopped = reason;
283
+ // A process blocked in filesystem I/O may not close even after SIGKILL.
284
+ // Arm the grace before kill: it can throw, return false, or emit events
285
+ // synchronously. Only close proves the destination can be cleaned up.
286
+ closeTimer = setTimeout(() => {
287
+ const error = extractionError(`${stopped}; tar did not close after termination; staging retained for safety`,
288
+ destination, details(null, null, processCode));
289
+ error.extractionMayBeRunning = true;
290
+ finish(error, true);
291
+ }, 1000);
292
+ try { child.kill('SIGKILL'); }
293
+ catch (error) { if (!settled) processCode ||= error.code; }
294
+ };
295
+ const onSpawn = () => { if (!settled) started = true; };
296
+ const onError = error => {
297
+ if (settled) return;
298
+ // Node leaves pid undefined when spawn is refused. A pid, a spawn event,
299
+ // or an attempted kill makes an error insufficient proof of termination.
300
+ if (!started && child.pid === undefined && !stopped) { spawnFailure(error); return; }
301
+ processCode ||= error.code;
302
+ stop('Native tar process failed');
303
+ };
304
+ const onData = chunk => {
305
+ if (settled) return;
306
+ started = true;
307
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
308
+ errorBytes += bytes.length;
309
+ if (captured < 65536) {
310
+ const part = Buffer.from(bytes.subarray(0, 65536 - captured));
311
+ chunks.push(part); captured += part.length;
312
+ }
313
+ if (errorBytes > 65536) stop('Native tar stderr exceeded its diagnostic limit');
314
+ };
315
+ // Wait for close after killing tar so staging cleanup cannot race extraction.
316
+ const onClose = (code, signal) => {
317
+ if (settled) return;
318
+ if (!stopped && code === 0 && !signal) finish();
319
+ else finish(extractionError(stopped || 'Native tar rejected the release archive', destination, details(code, signal, processCode)));
320
+ };
321
+ try {
322
+ child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
323
+ { shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
324
+ } catch (error) { spawnFailure(error); return; }
325
+ started = child.pid !== undefined && child.pid !== null;
326
+ timer = setTimeout(() => stop('Native tar extraction timed out'), Math.min(timeout, LIMITS.timeout));
327
+ child.once('spawn', onSpawn);
328
+ child.on('error', onError);
329
+ child.once('close', onClose);
330
+ child.stderr.on('data', onData);
230
331
  });
231
332
  return entries;
232
333
  }
233
- module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, TOP_DIRECTORIES, BUNDLE };
334
+ module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, diagnosticText, TOP_DIRECTORIES, BUNDLE };
package/lib/cli.js CHANGED
@@ -4,12 +4,16 @@ const { fail, readSmall } = require('./common');
4
4
  const { locations, releaseSpec, ensureInstalled, stageReporter, packageVersion } = require('./install');
5
5
  const { openSuite, FRONTENDS } = require('./runtime');
6
6
  const { bindAgent, executeAgent, agentCapabilities, WRITE_COMMANDS, MAX_PACKET_BYTES } = require('./agent');
7
+ const transfer = require('./transfer');
7
8
 
8
9
  const HELP = `WorkBite ${packageVersion} - Work Stack organizes the work. StackBite gets it moving.
9
10
 
10
11
  Usage:
11
12
  workbite install download, verify and install the pinned native suite
12
13
  workbite check verify the installed suite (no download, no processes)
14
+ workbite status [--json] inspect startup records without starting anything
15
+ workbite doctor [--json] explain startup evidence and its limits (read-only)
16
+ workbite recover --dry-run [--json] print a recovery plan only; never changes state
13
17
  workbite open --workspace ABS [--init] [--no-ui] [--frontend typescript|rust]
14
18
  start or reuse this workspace's Work Stack owner and the
15
19
  StackBite backend, then run the Work Stack terminal UI
@@ -18,7 +22,8 @@ Usage:
18
22
  workbite agent [--workspace ABS --workspace-uid UUID] COMMAND [--input-file FILE | --stdin] [flags]
19
23
  curated Work Stack agent commands against the running,
20
24
  WorkBite-owned owner (never starts one): capabilities, status,
21
- tasks list|create|update, context, checkpoint, operation-status
25
+ tasks list|create|update, context, checkpoint, operation-status,
26
+ export, import, import-status, import-rollback
22
27
  workbite mcp [--workspace ABS --workspace-uid UUID]
23
28
  WorkBite MCP server on stdio for the same agent commands
24
29
  workbite --version | --help
@@ -29,10 +34,22 @@ agent:
29
34
  and a packet from --input-file FILE or --stdin (UTF-8, at most 32768 bytes). An uncertain write
30
35
  is never replayed; check it with agent operation-status --intent-id ID.
31
36
 
37
+ Task transfer (archive at most 1 MiB; uses the running owner, never starts one):
38
+ workbite agent export --output tasks.json [--include-history] [--redact]
39
+ workbite agent import --input-file tasks.json --mode new --dry-run
40
+ workbite agent --workspace ABS --workspace-uid UUID import --input-file tasks.json --mode new
41
+ --intent-id ID --plan-digest sha256:HEX --expected-snapshot-digest sha256:HEX [--id-map map.json]
42
+ workbite agent import-status --intent-id ID
43
+ workbite agent --workspace ABS --workspace-uid UUID import-rollback --intent-id ID --import-id ID
44
+ --expected-snapshot-digest sha256:HEX
45
+ merge is also supported. --preserve-ids is explicit. Output files must be new local files.
46
+ Import unknowns require import-status with the SAME intent; no automatic replay or Task status changes.
47
+ Full workspace relocation uses official backup/restore.
48
+
32
49
  open:
33
50
  --workspace ABS absolute workspace folder; always wins over the remembered one
34
51
  --init create a new Work Stack workspace in a new or empty folder (needs --workspace)
35
- --no-ui start or reuse the backends, print one JSON summary and exit
52
+ --no-ui start or reuse the backends, print one JSON summary and exit; does not prove UI works
36
53
  --frontend NAME typescript (default): the familiar Work Stack UI, with same-window 9 Execution
37
54
  on execution bundles; rust: the explicit alternative unified UI (execution
38
55
  bundles only). Chosen per open, never saved.
@@ -62,10 +79,14 @@ const AGENT_FLAGS = Object.freeze({
62
79
  context: ['--task', '--view', '--format', '--source', '--fingerprint', '--if-fingerprint'],
63
80
  checkpoint: ['--intent-id'],
64
81
  'operation-status': ['--intent-id'],
82
+ export: ['--include-history', '--redact'],
83
+ import: ['--mode', '--dry-run', '--preserve-ids', '--intent-id', '--plan-digest', '--expected-snapshot-digest'],
84
+ 'import-status': ['--intent-id'],
85
+ 'import-rollback': ['--intent-id', '--import-id', '--expected-snapshot-digest'],
65
86
  });
66
- const FLAGS_WITHOUT_VALUE = new Set(['--workspace-revision', '--fingerprint']);
87
+ const FLAGS_WITHOUT_VALUE = new Set(['--workspace-revision', '--fingerprint', '--include-history', '--redact', '--dry-run', '--preserve-ids']);
67
88
  const REPEATABLE = new Set(['--source']);
68
- const PACKET_COMMANDS = new Set(['tasks create', 'tasks update', 'checkpoint']);
89
+ const PACKET_COMMANDS = new Set(['tasks create', 'tasks update', 'checkpoint', 'import']);
69
90
 
70
91
  function parseBinding(options, argument, next) {
71
92
  if (argument === '--workspace' && options.workspace === undefined && next !== undefined && !next.startsWith('--')) {
@@ -101,6 +122,11 @@ function parseAgent(rest) {
101
122
  for (let index = 0; index < rest.length; index++) {
102
123
  const argument = rest[index], next = rest[index + 1];
103
124
  if (parseBinding(options, argument, next)) { index++; continue; }
125
+ if (((argument === '--output' && name === 'export' && options.output === undefined)
126
+ || (argument === '--id-map' && name === 'import' && options.idMap === undefined))
127
+ && next !== undefined && !next.startsWith('--')) {
128
+ options[argument === '--output' ? 'output' : 'idMap'] = next; index++; continue;
129
+ }
104
130
  if (argument === '--input-file' && options.inputFile === undefined && !options.stdin && next !== undefined
105
131
  && !next.startsWith('--')) { options.inputFile = next; index++; continue; }
106
132
  if (argument === '--stdin' && !options.stdin && options.inputFile === undefined) { options.stdin = true; continue; }
@@ -118,7 +144,23 @@ function parseAgent(rest) {
118
144
  fail(PACKET_COMMANDS.has(name) ? `agent ${name} needs a packet from --input-file FILE or --stdin`
119
145
  : `agent ${name} does not take --input-file or --stdin`);
120
146
  }
121
- if (WRITE_COMMANDS.includes(name) && options.workspace === undefined) {
147
+ const value = flag => options.native[options.native.indexOf(flag) + 1];
148
+ const has = flag => options.native.includes(flag);
149
+ const dryRun = name === 'import' && has('--dry-run');
150
+ if (name === 'import') {
151
+ if (options.inputFile === undefined) fail('agent import requires --input-file FILE');
152
+ if (!has('--mode') || !['new', 'merge'].includes(value('--mode'))) fail('--mode must be new or merge');
153
+ const proofs = ['--intent-id', '--plan-digest', '--expected-snapshot-digest'];
154
+ if (dryRun && (proofs.some(has) || options.idMap !== undefined)) fail('Import preview does not accept write identity, proofs or --id-map');
155
+ if (!dryRun && proofs.some(flag => !has(flag))) fail('Import apply requires --intent-id, --plan-digest and --expected-snapshot-digest from preview');
156
+ }
157
+ if (['import-status', 'import-rollback'].includes(name) && !has('--intent-id')) fail(`agent ${name} requires --intent-id`);
158
+ if (name === 'import-rollback' && (!has('--import-id') || !has('--expected-snapshot-digest'))) fail('Import rollback requires --import-id and --expected-snapshot-digest');
159
+ for (const proof of ['--plan-digest', '--expected-snapshot-digest']) {
160
+ if (has(proof) && !transfer.DIGEST.test(value(proof))) fail(`${proof} must be sha256: followed by 64 lowercase hex digits`);
161
+ }
162
+ if (has('--intent-id') && name.startsWith('import') && !/^[A-Za-z0-9._:-]{8,128}$/.test(value('--intent-id'))) fail('Invalid transfer --intent-id');
163
+ if (WRITE_COMMANDS.includes(name) && !dryRun && options.workspace === undefined) {
122
164
  fail(`agent ${name} changes Work Stack; name the workspace with --workspace ABS --workspace-uid UUID`);
123
165
  }
124
166
  if (packet) options.native.push('--stdin');
@@ -142,6 +184,16 @@ function parseArguments(argv) {
142
184
  // Bare `workbite` (or only open options) means `workbite open`.
143
185
  const [command, ...rest] = argv.length && !argv[0].startsWith('--') ? argv : ['open', ...argv];
144
186
  if ((command === 'install' || command === 'check') && !rest.length) return { command };
187
+ if (['status', 'doctor', 'recover'].includes(command)) {
188
+ const options = { command, json: false, dryRun: false };
189
+ for (const argument of rest) {
190
+ if (argument === '--json' && !options.json) options.json = true;
191
+ else if (command === 'recover' && argument === '--dry-run' && !options.dryRun) options.dryRun = true;
192
+ else fail(`Unsupported or repeated ${command} option. See workbite --help.`);
193
+ }
194
+ if (command === 'recover' && !options.dryRun) fail('recover requires --dry-run; automatic repair is not supported.');
195
+ return options;
196
+ }
145
197
  if (command === 'agent') return parseAgent([...rest]);
146
198
  if (command === 'mcp') return parseMcp(rest);
147
199
  if (command === 'open') {
@@ -202,10 +254,26 @@ async function runAgent(parsed, { installed, stateRoot, environment, commands, t
202
254
  } else {
203
255
  binding = await bindAgent({ installed, stateRoot, environment, workspace: parsed.workspace,
204
256
  workspaceUid: parsed.workspaceUid, commands, timeouts });
205
- const packet = await readPacket(parsed, stdin || process.stdin);
257
+ if (parsed.output !== undefined) await transfer.outputPath(parsed.output);
258
+ if (parsed.idMap !== undefined) await transfer.outputPath(parsed.idMap);
259
+ const packet = parsed.name === 'import' ? await transfer.readArchive(parsed.inputFile) : await readPacket(parsed, stdin || process.stdin);
206
260
  result = await executeAgent(binding, parsed.native, { stdin: packet });
207
261
  }
208
262
  let output = result.stdout;
263
+ if (result.code === 0 && (parsed.output !== undefined || parsed.idMap !== undefined)) {
264
+ try {
265
+ const intentIndex = parsed.native.indexOf('--intent-id');
266
+ const data = transfer.resultData(result, parsed.name, binding.workspaceUid,
267
+ intentIndex === -1 ? undefined : parsed.native[intentIndex + 1]);
268
+ if (parsed.output !== undefined) await transfer.writePrivate(parsed.output, transfer.archive(data, binding.workspaceUid));
269
+ if (parsed.idMap !== undefined) await transfer.writePrivate(parsed.idMap, transfer.idMap(data.id_map));
270
+ } catch (error) {
271
+ // Apply may already be committed: preserve its receipt and never replay for a local file failure.
272
+ if (output) stdout.write(output);
273
+ stderr.write(`error: transfer file output failed: ${error.message}. Native operation was not replayed; retain this response and use import-status for any write.\n`);
274
+ return { code: 1, signal: null };
275
+ }
276
+ }
209
277
  if (parsed.name === 'status' && result.code === 0) {
210
278
  try {
211
279
  const document = JSON.parse(output);
@@ -228,6 +296,9 @@ async function main(argv = process.argv.slice(2), options = {}) {
228
296
  if (parsed.command === 'version') { stdout.write(`workbite ${packageVersion}\n`); return { code: 0, signal: null }; }
229
297
  const environment = options.environment || process.env;
230
298
  const platform = options.platform || process.platform;
299
+ if (['status', 'doctor', 'recover'].includes(parsed.command)) {
300
+ return require('./diagnostics').runDiagnostics(parsed, { ...options, environment, platform, stdout });
301
+ }
231
302
  const spec = releaseSpec(options.manifest || require('../release.json'), platform, options.arch || process.arch, options);
232
303
  const where = locations(environment, platform);
233
304
  // agent and mcp only use an already installed, verified suite: nothing is downloaded or started.
@@ -246,7 +317,8 @@ async function main(argv = process.argv.slice(2), options = {}) {
246
317
  return { code: 0, signal: null };
247
318
  }
248
319
  return openSuite({ installed, commands: options.commands, workspace: parsed.workspace, init: parsed.init, ui: parsed.ui,
249
- frontend: parsed.frontend, stateRoot, environment, spawnImpl: options.spawnImpl, execImpl: options.execImpl, stdout, timeouts: options.timeouts });
320
+ frontend: parsed.frontend, stateRoot, environment, spawnImpl: options.spawnImpl, execImpl: options.execImpl, stdout, stderr, timeouts: options.timeouts,
321
+ mobileBootstrap: options.mobileBootstrap });
250
322
  }
251
323
 
252
324
  module.exports = { main, parseArguments, HELP };