@tpsdev-ai/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/bin/tps.js +154 -7
  2. package/dist/bin/tps.js.map +1 -1
  3. package/dist/src/commands/backup.d.ts +19 -0
  4. package/dist/src/commands/backup.d.ts.map +1 -0
  5. package/dist/src/commands/backup.js +595 -0
  6. package/dist/src/commands/backup.js.map +1 -0
  7. package/dist/src/commands/bootstrap.d.ts +7 -0
  8. package/dist/src/commands/bootstrap.d.ts.map +1 -0
  9. package/dist/src/commands/bootstrap.js +255 -0
  10. package/dist/src/commands/bootstrap.js.map +1 -0
  11. package/dist/src/commands/git.d.ts +8 -0
  12. package/dist/src/commands/git.d.ts.map +1 -0
  13. package/dist/src/commands/git.js +53 -0
  14. package/dist/src/commands/git.js.map +1 -0
  15. package/dist/src/commands/identity.d.ts +1 -1
  16. package/dist/src/commands/identity.d.ts.map +1 -1
  17. package/dist/src/commands/identity.js +6 -6
  18. package/dist/src/commands/identity.js.map +1 -1
  19. package/dist/src/commands/mail.d.ts +1 -1
  20. package/dist/src/commands/mail.d.ts.map +1 -1
  21. package/dist/src/commands/mail.js +33 -7
  22. package/dist/src/commands/mail.js.map +1 -1
  23. package/dist/src/commands/office-manager.d.ts +147 -0
  24. package/dist/src/commands/office-manager.d.ts.map +1 -0
  25. package/dist/src/commands/office-manager.js +171 -0
  26. package/dist/src/commands/office-manager.js.map +1 -0
  27. package/dist/src/commands/office.d.ts +4 -11
  28. package/dist/src/commands/office.d.ts.map +1 -1
  29. package/dist/src/commands/office.js +266 -384
  30. package/dist/src/commands/office.js.map +1 -1
  31. package/dist/src/commands/secrets.d.ts +9 -0
  32. package/dist/src/commands/secrets.d.ts.map +1 -0
  33. package/dist/src/commands/secrets.js +54 -0
  34. package/dist/src/commands/secrets.js.map +1 -0
  35. package/dist/src/commands/status.d.ts +33 -0
  36. package/dist/src/commands/status.d.ts.map +1 -0
  37. package/dist/src/commands/status.js +407 -0
  38. package/dist/src/commands/status.js.map +1 -0
  39. package/dist/src/generators/brief.d.ts +6 -0
  40. package/dist/src/generators/brief.d.ts.map +1 -0
  41. package/dist/src/generators/brief.js +33 -0
  42. package/dist/src/generators/brief.js.map +1 -0
  43. package/dist/src/generators/claude-code.d.ts +1 -0
  44. package/dist/src/generators/claude-code.d.ts.map +1 -1
  45. package/dist/src/generators/claude-code.js +5 -0
  46. package/dist/src/generators/claude-code.js.map +1 -1
  47. package/dist/src/generators/codex.d.ts +1 -0
  48. package/dist/src/generators/codex.d.ts.map +1 -1
  49. package/dist/src/generators/codex.js +5 -0
  50. package/dist/src/generators/codex.js.map +1 -1
  51. package/dist/src/generators/openclaw.d.ts.map +1 -1
  52. package/dist/src/generators/openclaw.js +4 -0
  53. package/dist/src/generators/openclaw.js.map +1 -1
  54. package/dist/src/schema/manifest.d.ts +191 -44
  55. package/dist/src/schema/manifest.d.ts.map +1 -1
  56. package/dist/src/schema/manifest.js +58 -55
  57. package/dist/src/schema/manifest.js.map +1 -1
  58. package/dist/src/schema/sanitizer.d.ts.map +1 -1
  59. package/dist/src/schema/sanitizer.js +3 -1
  60. package/dist/src/schema/sanitizer.js.map +1 -1
  61. package/dist/src/utils/agent-info.js +1 -1
  62. package/dist/src/utils/agent-info.js.map +1 -1
  63. package/dist/src/utils/archive.d.ts +2 -9
  64. package/dist/src/utils/archive.d.ts.map +1 -1
  65. package/dist/src/utils/archive.js +90 -62
  66. package/dist/src/utils/archive.js.map +1 -1
  67. package/dist/src/utils/identity.d.ts +13 -3
  68. package/dist/src/utils/identity.d.ts.map +1 -1
  69. package/dist/src/utils/identity.js +109 -16
  70. package/dist/src/utils/identity.js.map +1 -1
  71. package/dist/src/utils/mail-handler.js +2 -2
  72. package/dist/src/utils/mail-handler.js.map +1 -1
  73. package/dist/src/utils/manifest.d.ts.map +1 -1
  74. package/dist/src/utils/manifest.js +17 -14
  75. package/dist/src/utils/manifest.js.map +1 -1
  76. package/dist/src/utils/noise-ik-transport.js +1 -1
  77. package/dist/src/utils/noise-ik-transport.js.map +1 -1
  78. package/dist/src/utils/nono.d.ts +1 -1
  79. package/dist/src/utils/nono.d.ts.map +1 -1
  80. package/dist/src/utils/nono.js.map +1 -1
  81. package/dist/src/utils/plain-tcp-transport.js +1 -1
  82. package/dist/src/utils/plain-tcp-transport.js.map +1 -1
  83. package/dist/src/utils/provision.d.ts.map +1 -1
  84. package/dist/src/utils/provision.js +8 -1
  85. package/dist/src/utils/provision.js.map +1 -1
  86. package/dist/src/utils/relay.js +4 -4
  87. package/dist/src/utils/relay.js.map +1 -1
  88. package/dist/src/utils/vault.d.ts +21 -0
  89. package/dist/src/utils/vault.d.ts.map +1 -0
  90. package/dist/src/utils/vault.js +67 -0
  91. package/dist/src/utils/vault.js.map +1 -0
  92. package/dist/src/utils/workspace.d.ts +14 -0
  93. package/dist/src/utils/workspace.d.ts.map +1 -0
  94. package/dist/src/utils/workspace.js +53 -0
  95. package/dist/src/utils/workspace.js.map +1 -0
  96. package/dist/src/utils/ws-noise-transport.js +2 -2
  97. package/dist/src/utils/ws-noise-transport.js.map +1 -1
  98. package/nono-profiles/tps-backup.toml +18 -0
  99. package/nono-profiles/tps-bootstrap.toml +20 -0
  100. package/nono-profiles/tps-office-manager.toml +21 -0
  101. package/nono-profiles/tps-restore.toml +18 -0
  102. package/nono-profiles/tps-status.toml +19 -0
  103. package/package.json +7 -27
  104. package/LICENSE +0 -201
  105. package/README.md +0 -79
@@ -1,17 +1,19 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, openSync, closeSync, copyFileSync } from "node:fs";
2
- import { join, dirname } from "node:path";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { spawn, spawnSync } from "node:child_process";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { sanitizeIdentifier } from "../schema/sanitizer.js";
6
- import { connectAndKeepAlive, startRelay, syncRemoteBranch } from "../utils/relay.js";
7
7
  import { connectionAlive, listHostStates } from "../utils/connection-state.js";
8
- import { sandboxSocketPath, isSandboxReady, waitForSandbox, sandboxExec, loadImageIntoSandbox } from "../utils/sandbox.js";
9
- import { provisionTeam } from "../utils/provision.js";
10
- import { fileURLToPath } from "node:url";
11
- import { fingerprint, loadHostIdentity, lookupBranch, registerBranch, revokeBranch } from "../utils/identity.js";
8
+ import { fingerprint, loadHostIdentity, registerBranch, revokeBranch } from "../utils/identity.js";
12
9
  import { NoiseIkTransport } from "../utils/noise-ik-transport.js";
10
+ import { provisionTeam } from "../utils/provision.js";
11
+ import { connectAndKeepAlive, startRelay } from "../utils/relay.js";
12
+ import { isSandboxReady, loadImageIntoSandbox, sandboxExec, sandboxSocketPath, waitForSandbox } from "../utils/sandbox.js";
13
+ import { MSG_JOIN_COMPLETE, MSG_MAIL_DELIVER } from "../utils/wire-mail.js";
13
14
  import { WsNoiseTransport } from "../utils/ws-noise-transport.js";
14
- import { MSG_JOIN_COMPLETE } from "../utils/wire-mail.js";
15
+ import { branchRoot as sharedBranchRoot, resolveTeamId, workspacePath as sharedWorkspacePath } from "../utils/workspace.js";
16
+ import { runOfficeManager, loadWorkspaceManifest } from "./office-manager.js";
15
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
18
  const BOOTSTRAP_TEMPLATE = `#!/bin/bash
17
19
  set -e
@@ -51,88 +53,75 @@ nohup openclaw gateway run --config "$CONFIG" > __WORKSPACE__/gateway.log 2>&1 &
51
53
  echo "Branch office agent ready (gateway pid $!)"
52
54
  `;
53
55
  function branchRoot() {
54
- return join(process.env.HOME || homedir(), ".tps", "branch-office");
56
+ return sharedBranchRoot();
57
+ }
58
+ function workspacePath(agentId) {
59
+ return sharedWorkspacePath(agentId);
60
+ }
61
+ function sandboxName(agentId) {
62
+ const teamId = resolveTeamId(agentId);
63
+ return `tps-${teamId}`;
64
+ }
65
+ function relayPidFile(agentId) {
66
+ return join(workspacePath(agentId), "relay.pid");
67
+ }
68
+ function outboxCounts(agentId) {
69
+ const ws = workspacePath(agentId);
70
+ const root = join(ws, "mail", "outbox");
71
+ const countDir = (dir) => existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith(".json")).length : 0;
72
+ return {
73
+ newCount: countDir(join(root, "new")),
74
+ curCount: countDir(join(root, "cur")),
75
+ failedCount: countDir(join(root, "failed")),
76
+ };
55
77
  }
56
78
  function validateAgent(agent) {
57
79
  if (!agent) {
58
- console.error("Agent is required.");
80
+ console.error("Agent name is required.");
59
81
  process.exit(1);
60
82
  }
61
83
  const safe = sanitizeIdentifier(agent);
62
84
  if (safe !== agent) {
63
- console.error(`Invalid agent id: ${agent}`);
85
+ console.error(`Invalid agent identifier: ${agent}`);
64
86
  process.exit(1);
65
87
  }
66
88
  return agent;
67
89
  }
68
- export function parseJoinToken(token) {
69
- let u;
70
- try {
71
- u = new URL(token);
72
- }
73
- catch {
74
- throw new Error("Invalid join token URL");
75
- }
76
- if (u.protocol !== "tps:")
77
- throw new Error("Join token must use tps:// scheme");
78
- const host = u.searchParams.get("host") || "";
79
- const portRaw = u.searchParams.get("port") || "";
80
- const transportRaw = u.searchParams.get("transport") || "ws";
81
- const pubkeyRaw = u.searchParams.get("pubkey") || "";
82
- const sigRaw = u.searchParams.get("sigpubkey") || "";
83
- const fp = u.searchParams.get("fp") || "";
84
- if (!host)
85
- throw new Error("Join token missing host");
86
- const transport = transportRaw === "tcp" ? "tcp" : "ws";
87
- const port = Number(portRaw);
88
- if (!Number.isFinite(port) || port <= 0 || port > 65535) {
89
- throw new Error("Join token has invalid port");
90
- }
91
- if (!pubkeyRaw)
92
- throw new Error("Join token missing pubkey");
93
- if (!sigRaw)
94
- throw new Error("Join token missing sigpubkey");
95
- if (!fp)
96
- throw new Error("Join token missing fingerprint");
97
- let encryptionPubkey;
98
- let signingPubkey;
90
+ function resolveSandboxId(agentId) {
91
+ const result = spawnSync("nono", ["list", "--json"], { encoding: "utf8" });
92
+ if (result.status !== 0)
93
+ return null;
99
94
  try {
100
- encryptionPubkey = new Uint8Array(Buffer.from(pubkeyRaw, "base64url"));
101
- signingPubkey = new Uint8Array(Buffer.from(sigRaw, "base64url"));
95
+ const states = JSON.parse(result.stdout);
96
+ const target = sandboxName(agentId);
97
+ const found = states.find((s) => s.name === target);
98
+ return found ? found.id : null;
102
99
  }
103
100
  catch {
104
- throw new Error("Join token contains invalid key encoding");
105
- }
106
- if (encryptionPubkey.length !== 32)
107
- throw new Error("Join token encryption pubkey must be 32 bytes");
108
- if (signingPubkey.length !== 32)
109
- throw new Error("Join token signing pubkey must be 32 bytes");
110
- const normalizedFp = fp.startsWith("sha256:") ? fp : `sha256:${fp}`;
111
- const expected = `sha256:${fingerprint(signingPubkey)}`;
112
- if (expected !== normalizedFp) {
113
- throw new Error("Join token fingerprint does not match signing key");
101
+ return null;
114
102
  }
115
- return { host, port, transport, encryptionPubkey, signingPubkey, fingerprint: normalizedFp };
116
- }
117
- function workspacePath(agent) {
118
- return join(branchRoot(), agent);
119
103
  }
120
- function sandboxName(agent) {
121
- return `tps-${agent}`;
122
- }
123
- function sandboxAgent() {
124
- const raw = process.env.TPS_SANDBOX_AGENT || "claude";
125
- const safe = sanitizeIdentifier(raw);
126
- if (safe !== raw) {
127
- throw new Error(`Invalid sandbox agent: ${raw}`);
104
+ export function parseJoinToken(tokenUrl) {
105
+ const url = new URL(tokenUrl);
106
+ if (url.protocol !== "tps:" || url.host !== "join") {
107
+ throw new Error("Invalid join token protocol. Must be tps://join?...");
128
108
  }
129
- return raw;
130
- }
131
- function idFile(agent) {
132
- return join(workspacePath(agent), "sandbox.id");
133
- }
134
- function relayPidFile(agent) {
135
- return join(workspacePath(agent), "relay.pid");
109
+ const host = url.searchParams.get("host");
110
+ const port = Number(url.searchParams.get("port"));
111
+ const pubkey = url.searchParams.get("pubkey");
112
+ const sigpubkey = url.searchParams.get("sigpubkey");
113
+ const fp = url.searchParams.get("fp");
114
+ if (!host || isNaN(port) || !pubkey || !sigpubkey || !fp) {
115
+ throw new Error("Invalid join token: missing required parameters");
116
+ }
117
+ return {
118
+ host,
119
+ port,
120
+ transport: url.searchParams.get("transport") || "ws",
121
+ encryptionPubkey: new Uint8Array(Buffer.from(pubkey, "base64url")),
122
+ signingPubkey: new Uint8Array(Buffer.from(sigpubkey, "base64url")),
123
+ fingerprint: fp,
124
+ };
136
125
  }
137
126
  function setupWorkspace(agent) {
138
127
  const ws = workspacePath(agent);
@@ -143,11 +132,11 @@ function setupWorkspace(agent) {
143
132
  mkdirSync(join(ws, "mail", "outbox", "failed"), { recursive: true });
144
133
  mkdirSync(join(ws, "mail", "outbox", "paused"), { recursive: true });
145
134
  const bootstrap = join(ws, "bootstrap.sh");
146
- // Security: never source bootstrap from process.cwd().
147
- // Use the trusted in-code template to avoid cwd injection.
148
- // ops-15.5: Don't overwrite existing bootstrap.sh if user customized it.
149
135
  if (!existsSync(bootstrap)) {
150
- const template = BOOTSTRAP_TEMPLATE.replaceAll("__WORKSPACE__", ws);
136
+ const teamRoot = join(branchRoot(), agent);
137
+ const template = BOOTSTRAP_TEMPLATE
138
+ .replaceAll("__WORKSPACE__", ws)
139
+ .replaceAll("__TEAM_ROOT__", teamRoot);
151
140
  writeFileSync(bootstrap, template, { mode: 0o755 });
152
141
  }
153
142
  else {
@@ -155,336 +144,108 @@ function setupWorkspace(agent) {
155
144
  }
156
145
  return ws;
157
146
  }
158
- function resolveSandboxId(agent) {
159
- const sid = idFile(agent);
160
- if (existsSync(sid)) {
161
- return readFileSync(sid, "utf-8").trim();
162
- }
163
- const listed = spawnSync("docker", ["sandbox", "ls", "--json"], { encoding: "utf-8" });
164
- if (listed.status !== 0)
165
- return null;
166
- try {
167
- const parsed = JSON.parse(listed.stdout || "{}");
168
- const rows = parsed.vms || [];
169
- const expected = sandboxName(agent).toLowerCase();
170
- const match = rows.find((r) => (r.name || "").toLowerCase() === expected);
171
- if (!match)
172
- return null;
173
- return (match.name || match.id || match.sandboxId || null);
174
- }
175
- catch {
176
- return null;
177
- }
178
- }
179
- function relayLogFile(agent) {
180
- return join(workspacePath(agent), "relay.log");
181
- }
182
- function startRelayProcess(agent) {
183
- const pidFile = relayPidFile(agent);
184
- if (existsSync(pidFile)) {
185
- const pid = Number(readFileSync(pidFile, "utf-8").trim());
186
- try {
187
- // Check if process exists (signal 0)
188
- process.kill(pid, 0);
189
- console.log(`Relay already running (pid ${pid})`);
190
- return;
191
- }
192
- catch {
193
- // Process dead, proceed to spawn new one
194
- }
195
- }
196
- const logPath = relayLogFile(agent);
197
- const logFd = openSync(logPath, "a");
198
- const child = spawn(process.execPath, [process.argv[1], "office", "relay", agent], {
199
- detached: true,
200
- stdio: ["ignore", logFd, logFd],
201
- env: { ...process.env },
202
- });
203
- child.unref();
204
- // Close the fd in the parent so we don't hold it open (child has it now)
205
- // Wait, spawn with detached doesn't automatically close fds in parent?
206
- // Actually, passing fd to stdio options duplicates it to child. Parent can close.
207
- // But we need to be careful not to close it before spawn uses it? Spawn is synchronous in setup.
208
- // Node docs say: "The file descriptor is duplicated in the child process."
209
- // Safe to close in parent after spawn returns.
210
- try {
211
- closeSync(logFd);
212
- }
213
- catch { }
214
- writeFileSync(relayPidFile(agent), `${child.pid}\n`, "utf-8");
215
- }
216
- function stopRelayProcess(agent) {
217
- const pf = relayPidFile(agent);
218
- if (!existsSync(pf))
219
- return;
220
- const pid = Number(readFileSync(pf, "utf-8").trim());
221
- if (pid > 0) {
222
- try {
223
- process.kill(pid, "SIGTERM");
224
- }
225
- catch {
226
- // already stopped
227
- }
228
- }
229
- }
230
- function outboxCounts(agent) {
231
- const ws = workspacePath(agent);
232
- const outNew = join(ws, "mail", "outbox", "new");
233
- const outCur = join(ws, "mail", "outbox", "cur");
234
- const outFailed = join(ws, "mail", "outbox", "failed");
235
- const newCount = existsSync(outNew) ? readdirSync(outNew).filter((f) => f.endsWith(".json")).length : 0;
236
- const curCount = existsSync(outCur) ? readdirSync(outCur).filter((f) => f.endsWith(".json")).length : 0;
237
- const failedCount = existsSync(outFailed) ? readdirSync(outFailed).filter((f) => f.endsWith(".json")).length : 0;
238
- return { newCount, curCount, failedCount };
239
- }
240
147
  export async function runOffice(args) {
241
148
  switch (args.action) {
242
- case "join": {
243
- const agent = validateAgent(args.agent);
244
- if (!args.joinToken) {
245
- console.error("Usage: tps office join <name> <join-token-url>");
246
- process.exit(1);
247
- }
248
- const token = parseJoinToken(args.joinToken);
249
- const existing = lookupBranch(agent);
250
- if (existing) {
251
- console.error(`Branch '${agent}' is already registered. Revoke first or use a different name.`);
252
- process.exit(1);
253
- }
254
- registerBranch(agent, token.signingPubkey, {
255
- fingerprint: `sha256:${fingerprint(token.signingPubkey)}`,
256
- trust: "standard",
257
- }, token.encryptionPubkey);
258
- console.log(`Connecting to ${token.host}:${token.port}...`);
259
- const hostKp = loadHostIdentity();
260
- const wire = token.transport === "ws" ? new WsNoiseTransport(hostKp) : new NoiseIkTransport(hostKp);
261
- const channel = await wire.connect({
262
- host: token.host,
263
- port: token.port,
264
- branchId: agent,
265
- hostPublicKey: token.encryptionPubkey,
266
- });
267
- console.log(`Noise_IK handshake OK — branch fingerprint verified: ${token.fingerprint}`);
268
- await channel.send({
269
- type: MSG_JOIN_COMPLETE,
270
- seq: 0,
271
- ts: new Date().toISOString(),
272
- body: {
273
- hostPubkey: Buffer.from(hostKp.encryption.publicKey).toString("base64url"),
274
- hostFingerprint: fingerprint(hostKp.encryption.publicKey),
275
- hostId: process.env.TPS_HOST_ID || "host",
276
- },
277
- });
278
- const ws = workspacePath(agent);
279
- mkdirSync(ws, { recursive: true });
280
- const remoteRecord = {
281
- host: token.host,
282
- port: token.port,
283
- branchId: agent,
284
- fingerprint: token.fingerprint,
285
- pubkey: Buffer.from(token.encryptionPubkey).toString("base64url"),
286
- joinedAt: new Date().toISOString(),
287
- transport: token.transport,
288
- };
289
- writeFileSync(join(ws, "remote.json"), JSON.stringify(remoteRecord, null, 2), "utf-8");
290
- await channel.close();
291
- console.log(`Branch '${agent}' registered.`);
292
- console.log("Host pubkey sent to branch.");
293
- console.log("Remote branch office ready.");
294
- return;
295
- }
296
149
  case "start": {
297
- const agent = validateAgent(args.agent);
298
- // ops-17: Check for manifest mode (team provisioning)
299
150
  if (args.manifest) {
300
151
  try {
301
152
  provisionTeam(args.manifest, branchRoot());
302
- console.log(`Team '${agent}' provisioned from manifest.`);
153
+ if (args.soundstage && args.agent) {
154
+ const team = validateAgent(args.agent);
155
+ const teamRoot = join(branchRoot(), team);
156
+ const ws = join(teamRoot, "workspace");
157
+ const marker = join(teamRoot, "soundstage.json");
158
+ mkdirSync(teamRoot, { recursive: true });
159
+ writeFileSync(marker, JSON.stringify({ enabled: true, startedAt: new Date().toISOString() }));
160
+ const teamBootstrap = join(teamRoot, "bootstrap.sh");
161
+ let bs = BOOTSTRAP_TEMPLATE
162
+ .replaceAll("__WORKSPACE__", ws)
163
+ .replaceAll("__TEAM_ROOT__", teamRoot);
164
+ const workspaceBootstrap = join(ws, "bootstrap.sh");
165
+ if (existsSync(workspaceBootstrap)) {
166
+ bs = readFileSync(workspaceBootstrap, "utf-8").replaceAll("__TEAM_ROOT__", teamRoot);
167
+ }
168
+ writeFileSync(teamBootstrap, bs, { mode: 0o755 });
169
+ }
170
+ console.log(`Team provisioned from manifest.`);
303
171
  }
304
172
  catch (e) {
305
173
  console.error(`Failed to provision team: ${e.message}`);
306
174
  process.exit(1);
307
175
  }
176
+ return;
308
177
  }
178
+ const agent = validateAgent(args.agent);
179
+ const sName = sandboxName(agent);
309
180
  const ws = setupWorkspace(agent);
310
181
  if (args.soundstage) {
311
- // Write soundstage marker OUTSIDE workspace mount (agents can't detect it)
182
+ console.log("🎬 Soundstage mode enabled (Mock LLM, local isolation)");
312
183
  const teamRoot = join(branchRoot(), agent);
313
- const marker = {
314
- mode: "soundstage",
315
- createdAt: new Date().toISOString(),
316
- agent,
317
- manifest: args.manifest || null,
318
- mockLlmPort: 11434,
319
- };
320
- // Use teamRoot instead of ws to place outside workspace
321
- writeFileSync(join(teamRoot, "soundstage.json"), JSON.stringify(marker, null, 2), "utf-8");
322
- // Rewrite openclaw.json to point at mock LLM
323
- const configPath = join(ws, ".openclaw", "openclaw.json");
324
- if (existsSync(configPath)) {
325
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
326
- if (config.agents?.defaults?.model) {
327
- config.agents.defaults.model.primary = "openai-compatible/mock-soundstage";
328
- config.agents.defaults.model.fallbacks = [];
329
- }
330
- // Add base URL for the mock
331
- config.agents = config.agents || {};
332
- config.agents.defaults = config.agents.defaults || {};
333
- config.agents.defaults.baseUrl = "http://127.0.0.1:11434/v1";
334
- writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
335
- }
336
- // Copy mock LLM server into team root (NOT workspace — agents can't modify it)
337
- const mockSrc = join(__dirname, "..", "soundstage", "mock-llm.js");
338
- const mockDst = join(teamRoot, "mock-llm.js");
339
- if (!existsSync(mockSrc)) {
340
- throw new Error("Soundstage mock LLM not found. Run `bun run build` first.");
341
- }
342
- copyFileSync(mockSrc, mockDst);
343
- // Substitute __TEAM_ROOT__ in bootstrap (setupWorkspace only handles __WORKSPACE__)
344
- const bootstrapPath = join(ws, "bootstrap.sh");
345
- if (existsSync(bootstrapPath)) {
346
- const bs = readFileSync(bootstrapPath, "utf-8").replaceAll("__TEAM_ROOT__", teamRoot);
347
- writeFileSync(bootstrapPath, bs, { mode: 0o755 });
184
+ const marker = join(teamRoot, "soundstage.json");
185
+ mkdirSync(teamRoot, { recursive: true });
186
+ writeFileSync(marker, JSON.stringify({ enabled: true, startedAt: new Date().toISOString() }));
187
+ const soundstageImage = join(__dirname, "..", "..", "images", "tps-soundstage.tar");
188
+ if (existsSync(soundstageImage)) {
189
+ console.log(`Loading soundstage image: ${soundstageImage}`);
190
+ loadImageIntoSandbox(sName, soundstageImage);
348
191
  }
349
- }
350
- const sName = sandboxName(agent);
351
- const skipVm = process.env.TPS_OFFICE_SKIP_VM === "1";
352
- if (!skipVm) {
353
- // Idempotency: check if sandbox already exists
354
- const existingId = resolveSandboxId(agent);
355
- if (!existingId) {
356
- const sAgent = sandboxAgent();
357
- console.log(`Creating sandbox ${sName}...`);
358
- // docker sandbox create hangs indefinitely (v0.11.0 bug) even after
359
- // the VM is ready. Spawn it in the background and wait for the socket.
360
- const createLog = join(ws, "create.log");
361
- const createLogFd = openSync(createLog, "a");
362
- const createArgs = ["sandbox", "create", "--name", sName];
363
- // Soundstage uses a pre-built image with openclaw already installed
364
- if (args.soundstage) {
365
- createArgs.push("--template", "tps-soundstage:latest");
366
- }
367
- createArgs.push(sAgent, ws);
368
- const createChild = spawn("docker", createArgs, {
369
- detached: true,
370
- stdio: ["ignore", createLogFd, createLogFd],
371
- });
372
- createChild.unref();
373
- try {
374
- closeSync(createLogFd);
375
- }
376
- catch { }
377
- // Wait for VM socket to appear (the create command keeps running but VM is ready)
378
- console.log("Waiting for sandbox VM to boot...");
379
- if (!waitForSandbox(sName, 90000)) {
380
- // Check if sandbox appeared in ls even if socket isn't ready
381
- const check = spawnSync("docker", ["sandbox", "ls", "--json"], { encoding: "utf-8" });
382
- const vms = JSON.parse(check.stdout || "{}").vms || [];
383
- const found = vms.find((v) => v.name === sName);
384
- if (!found) {
385
- console.error(`Sandbox creation failed. Check ${createLog}`);
386
- process.exit(1);
387
- }
388
- // VM exists but socket not ready — wait longer
389
- console.log("VM created, waiting for daemon...");
390
- if (!waitForSandbox(sName, 30000)) {
391
- console.error("Sandbox VM daemon did not become ready.");
392
- process.exit(1);
393
- }
394
- }
395
- console.log("Sandbox VM ready.");
192
+ const bootstrap = join(ws, "bootstrap.sh");
193
+ if (existsSync(bootstrap)) {
194
+ const bs = readFileSync(bootstrap, "utf-8").replaceAll("__TEAM_ROOT__", teamRoot);
195
+ writeFileSync(bootstrap, bs, { mode: 0o755 });
396
196
  }
397
197
  }
398
- const id = sName;
399
- writeFileSync(idFile(agent), `${id}\n`, "utf-8");
400
- const sock = sandboxSocketPath(id);
401
- if (!skipVm) {
402
- // Ensure VM is ready (may already be if we just created it above)
403
- if (!isSandboxReady(id)) {
404
- console.log("Waiting for sandbox VM...");
405
- if (!waitForSandbox(id, 60000)) {
406
- console.error("Sandbox VM did not become ready in 60s.");
407
- process.exit(1);
408
- }
409
- }
410
- // Load base image for bootstrap execution
411
- console.log("Loading base image into sandbox...");
412
- if (!loadImageIntoSandbox(id, "node:22-alpine")) {
413
- if (!loadImageIntoSandbox(id, "alpine:latest")) {
414
- console.error("Failed to load base image into sandbox VM.");
415
- process.exit(1);
416
- }
417
- }
418
- // Execute bootstrap via direct socket (workaround for docker sandbox exec bug)
419
- console.log("Running bootstrap...");
420
- const exec = sandboxExec(id, ["sh", join(ws, "bootstrap.sh")], {
421
- workspace: ws,
422
- image: "node:22-alpine",
423
- });
424
- if (exec.status !== 0) {
425
- const fallback = sandboxExec(id, ["sh", join(ws, "bootstrap.sh")], {
426
- workspace: ws,
427
- image: "alpine:latest",
428
- });
429
- if (fallback.status !== 0) {
430
- console.error(fallback.stderr || fallback.stdout || "Bootstrap failed.");
431
- process.exit(1);
432
- }
433
- }
198
+ const teamId = resolveTeamId(agent);
199
+ const isTeam = teamId !== agent;
200
+ if (isTeam) {
201
+ console.log(`(Shared team sandbox: ${teamId})`);
434
202
  }
435
- if (process.env.TPS_OFFICE_SKIP_RELAY !== "1") {
436
- startRelayProcess(agent);
203
+ if (process.env.TPS_OFFICE_SKIP_VM === "1") {
204
+ console.log(`✓ Sandbox ready for ${agent} (SKIPPED).`);
205
+ return;
437
206
  }
438
- console.log(`Sandbox started for ${agent}`);
439
- console.log(`ID: ${id}`);
440
- console.log(`Socket: ${sock}`);
441
- console.log(`Workspace: ${ws}`);
442
- return;
443
- }
444
- case "stop": {
445
- const agent = validateAgent(args.agent);
446
- stopRelayProcess(agent);
447
- const id = resolveSandboxId(agent);
448
- if (!id) {
449
- console.error(`No sandbox found for agent: ${agent}`);
207
+ console.log(`Starting sandbox VM for ${agent}...`);
208
+ spawnSync("nono", ["start", sName, "--mount", `${ws}:/workspace`, "--image", "node:22-alpine"], { stdio: "inherit" });
209
+ if (!waitForSandbox(sName)) {
210
+ console.error("Timed out waiting for sandbox to be ready.");
450
211
  process.exit(1);
451
212
  }
452
- const stop = spawnSync("docker", ["sandbox", "stop", id], { encoding: "utf-8" });
453
- if (stop.status !== 0) {
454
- console.error(stop.stderr || stop.stdout || `Failed to stop sandbox ${id}.`);
455
- process.exit(1);
213
+ console.log(`✓ Sandbox ready for ${agent}.`);
214
+ // Auto-run Office Manager if a workspace manifest exists
215
+ if (loadWorkspaceManifest(ws)) {
216
+ console.log("Workspace manifest found — running Office Manager...");
217
+ await runOfficeManager(ws, { dryRun: false });
456
218
  }
457
- console.log(`Stopped sandbox ${id} (${agent})`);
458
219
  return;
459
220
  }
460
- case "revoke": {
221
+ case "setup": {
461
222
  const agent = validateAgent(args.agent);
462
- const existing = lookupBranch(agent);
463
- if (!existing) {
464
- console.error(`Branch '${agent}' not found in registry.`);
223
+ const ws = workspacePath(agent);
224
+ const ok = await runOfficeManager(ws, { dryRun: args.dryRun ?? false });
225
+ if (!ok)
465
226
  process.exit(1);
466
- }
467
- revokeBranch(agent, "manual revocation");
468
- console.log(`Branch '${agent}' revoked. Run 'tps branch init' on the remote to re-join.`);
469
- return;
470
- }
471
- case "sync": {
472
- const agent = validateAgent(args.agent);
473
- const result = await syncRemoteBranch(agent);
474
- console.log(`Sync complete. Received ${result.received} message(s) from ${agent}.`);
475
227
  return;
476
228
  }
477
- case "connect": {
229
+ case "stop": {
478
230
  const agent = validateAgent(args.agent);
479
- console.log(`Connecting to ${agent}... (Ctrl-C to stop)`);
480
- const cleanup = await connectAndKeepAlive(agent);
481
- const shutdown = async () => {
482
- await cleanup();
483
- process.exit(0);
484
- };
485
- process.on("SIGTERM", shutdown);
486
- process.on("SIGINT", shutdown);
487
- await new Promise(() => { });
231
+ const sName = sandboxName(agent);
232
+ const soundstageMarker = join(branchRoot(), agent, "soundstage.json");
233
+ if (existsSync(soundstageMarker)) {
234
+ try {
235
+ unlinkSync(soundstageMarker);
236
+ }
237
+ catch { }
238
+ }
239
+ const sid = resolveSandboxId(agent);
240
+ if (!sid) {
241
+ process.stderr.write("No sandbox found for agent\n");
242
+ process.exit(1);
243
+ }
244
+ console.log(`Stopping sandbox VM for ${agent}...`);
245
+ const stopResult = spawnSync("nono", ["stop", sName], { stdio: "inherit" });
246
+ if (stopResult.status !== 0) {
247
+ process.exit(stopResult.status ?? 1);
248
+ }
488
249
  return;
489
250
  }
490
251
  case "list": {
@@ -535,16 +296,12 @@ export async function runOffice(args) {
535
296
  console.log(`Workspace: ${ws}`);
536
297
  if (isSoundstage) {
537
298
  console.log(`Mode: 🎬 soundstage (mock LLM, real sandbox)`);
538
- console.log(`Sandbox: ${sid || "not running"}${vmReady ? " (VM ready)" : ""}`);
539
- }
540
- else {
541
- console.log(`Sandbox: ${sid || "not running"}${vmReady ? " (VM ready)" : ""}`);
542
299
  }
300
+ console.log(`Sandbox: ${sid || "not running"}${vmReady ? " (VM ready)" : ""}`);
543
301
  if (sid)
544
302
  console.log(`Socket: ${sandboxSocketPath(sName2)}`);
545
303
  console.log(`Relay: ${relayRunning ? "running" : "stopped"}`);
546
304
  console.log(`Outbox pending: ${counts.newCount} (cur=${counts.curCount}, failed=${counts.failedCount})`);
547
- // Check for paused messages (loop detection)
548
305
  const pausedDir = join(ws, "mail", "outbox", "paused");
549
306
  if (existsSync(pausedDir)) {
550
307
  const pausedCount = readdirSync(pausedDir).filter((f) => f.endsWith(".json")).length;
@@ -566,7 +323,6 @@ export async function runOffice(args) {
566
323
  stop();
567
324
  process.exit(0);
568
325
  });
569
- // keep process alive
570
326
  setInterval(() => { }, 60_000);
571
327
  return;
572
328
  }
@@ -592,6 +348,132 @@ export async function runOffice(args) {
592
348
  if (result.stderr)
593
349
  process.stderr.write(result.stderr);
594
350
  process.exit(result.status ?? 1);
351
+ return;
352
+ }
353
+ case "join": {
354
+ if (!args.agent || !args.joinToken) {
355
+ console.error("Usage: tps office join <name> <join-token>");
356
+ process.exit(1);
357
+ }
358
+ const agent = validateAgent(args.agent);
359
+ const token = parseJoinToken(args.joinToken);
360
+ registerBranch(agent, token.signingPubkey, {
361
+ fingerprint: `sha256:${fingerprint(token.signingPubkey)}`,
362
+ trust: "standard",
363
+ }, token.encryptionPubkey);
364
+ console.log(`Connecting to ${token.host}:${token.port}...`);
365
+ const hostKp = await loadHostIdentity();
366
+ const wire = token.transport === "ws" ? new WsNoiseTransport(hostKp) : new NoiseIkTransport(hostKp);
367
+ const channel = await wire.connect({
368
+ host: token.host,
369
+ port: token.port,
370
+ branchId: agent,
371
+ hostPublicKey: token.encryptionPubkey,
372
+ });
373
+ console.log(`Noise_IK handshake OK — branch fingerprint verified: ${token.fingerprint}`);
374
+ await channel.send({
375
+ type: MSG_JOIN_COMPLETE,
376
+ seq: 0,
377
+ ts: new Date().toISOString(),
378
+ body: {
379
+ hostPubkey: Buffer.from(hostKp.encryption.publicKey).toString("base64url"),
380
+ hostFingerprint: fingerprint(hostKp.encryption.publicKey),
381
+ hostId: process.env.TPS_HOST_ID || "host",
382
+ },
383
+ });
384
+ const ws = workspacePath(agent);
385
+ mkdirSync(ws, { recursive: true });
386
+ const remoteRecord = {
387
+ host: token.host,
388
+ port: token.port,
389
+ branchId: agent,
390
+ fingerprint: token.fingerprint,
391
+ pubkey: Buffer.from(token.encryptionPubkey).toString("base64url"),
392
+ joinedAt: new Date().toISOString(),
393
+ transport: token.transport,
394
+ };
395
+ writeFileSync(join(ws, "remote.json"), JSON.stringify(remoteRecord, null, 2), "utf-8");
396
+ await channel.close();
397
+ console.log(`Branch '${agent}' registered.`);
398
+ console.log("Host pubkey sent to branch.");
399
+ return;
400
+ }
401
+ case "revoke": {
402
+ const agent = validateAgent(args.agent);
403
+ revokeBranch(agent, "manual revocation");
404
+ const ws = workspacePath(agent);
405
+ const rPath = join(ws, "remote.json");
406
+ if (existsSync(rPath)) {
407
+ try {
408
+ unlinkSync(rPath);
409
+ }
410
+ catch { }
411
+ }
412
+ console.log(`Branch '${agent}' revoked.`);
413
+ return;
414
+ }
415
+ case "sync": {
416
+ if (!args.agent) {
417
+ console.error("Usage: tps office sync <name>");
418
+ process.exit(1);
419
+ }
420
+ const { syncRemoteBranch: syncRemote } = await import("../utils/relay.js");
421
+ const { received } = await syncRemote(args.agent);
422
+ console.log(`Sync complete. Received ${received} message(s).`);
423
+ return;
424
+ }
425
+ case "connect": {
426
+ if (!args.agent) {
427
+ console.error("Usage: tps office connect <name>");
428
+ process.exit(1);
429
+ }
430
+ const hostKp = await loadHostIdentity();
431
+ const stop = await connectAndKeepAlive(args.agent, {
432
+ onMessage: (msg) => {
433
+ if (msg.type === MSG_MAIL_DELIVER) {
434
+ console.log(`\n[${new Date().toLocaleTimeString()}] ✉️ Mail received`);
435
+ }
436
+ }
437
+ });
438
+ console.log(`Persistent connection active for '${args.agent}'. Press Ctrl+C to disconnect.`);
439
+ process.on("SIGINT", async () => {
440
+ await stop();
441
+ process.exit(0);
442
+ });
443
+ setInterval(() => { }, 60_000);
444
+ return;
445
+ }
446
+ case "kill": {
447
+ let killed = 0;
448
+ const { listHostStates, clearHostState } = await import("../utils/connection-state.js");
449
+ const states = listHostStates();
450
+ for (const s of states) {
451
+ try {
452
+ process.kill(s.pid, "SIGTERM");
453
+ clearHostState(s.branch);
454
+ killed++;
455
+ }
456
+ catch {
457
+ clearHostState(s.branch);
458
+ }
459
+ }
460
+ const pidPath = join(process.env.HOME || homedir(), ".tps", "branch", "branch.pid");
461
+ if (existsSync(pidPath)) {
462
+ try {
463
+ const pid = Number(readFileSync(pidPath, "utf-8").trim());
464
+ if (pid) {
465
+ process.kill(pid, "SIGTERM");
466
+ killed++;
467
+ }
468
+ }
469
+ catch { }
470
+ try {
471
+ rmSync(pidPath, { force: true });
472
+ }
473
+ catch { }
474
+ }
475
+ console.log(`Kill switch engaged. Terminated ${killed} TPS process(es).`);
476
+ break;
595
477
  }
596
478
  }
597
479
  }