@xingular/harness 0.1.1 → 0.1.3

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/dist/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { resolve as resolve9 } from "node:path";
5
+
3
6
  // src/core/bus.ts
4
7
  var Bus = class {
5
8
  listeners = /* @__PURE__ */ new Set();
@@ -887,9 +890,9 @@ var Accounts = class {
887
890
  const interaction = {
888
891
  signal: controller.signal,
889
892
  notify: (notification) => event(notification),
890
- prompt: (prompt2) => new Promise((resolve9, reject) => {
893
+ prompt: (prompt2) => new Promise((resolve10, reject) => {
891
894
  const promptId = randomUUID2();
892
- login.prompts.set(promptId, { resolve: resolve9, reject });
895
+ login.prompts.set(promptId, { resolve: resolve10, reject });
893
896
  prompt2.signal?.addEventListener("abort", () => {
894
897
  login.prompts.delete(promptId);
895
898
  event({ type: "promptClosed", promptId });
@@ -4622,14 +4625,14 @@ var piResolver = async ({ cwd, agentDir, install: install3, packages }) => {
4622
4625
  const settings = pi.SettingsManager.create(cwd, agentDir);
4623
4626
  const shared = new Set((settings.getGlobalSettings().packages ?? []).map((entry) => typeof entry === "string" ? entry : entry.source));
4624
4627
  const manager2 = () => new pi.DefaultPackageManager({ cwd, agentDir, settingsManager: settings, builtinExtensions: [...PI_BUILTINS] });
4625
- const resolve9 = (installing) => manager2().resolve(async (source2) => installing && shared.has(source2) ? "install" : "skip");
4628
+ const resolve10 = (installing) => manager2().resolve(async (source2) => installing && shared.has(source2) ? "install" : "skip");
4626
4629
  let resolved2;
4627
4630
  try {
4628
- resolved2 = install3 ? await withoutInstallScripts(() => resolve9(true)) : await resolve9(false);
4631
+ resolved2 = install3 ? await withoutInstallScripts(() => resolve10(true)) : await resolve10(false);
4629
4632
  } catch (error) {
4630
4633
  if (!install3) throw error;
4631
4634
  log("a plugin package could not be installed", error instanceof Error ? error.message : String(error));
4632
- resolved2 = await resolve9(false);
4635
+ resolved2 = await resolve10(false);
4633
4636
  }
4634
4637
  const found = [];
4635
4638
  const add = (paths) => {
@@ -6249,8 +6252,8 @@ var cancelActive = null;
6249
6252
  function listenForCallback(expectedState, clientName) {
6250
6253
  return new Promise((ready, failed) => {
6251
6254
  let settle;
6252
- const result = new Promise((resolve9, reject) => {
6253
- settle = { resolve: resolve9, reject };
6255
+ const result = new Promise((resolve10, reject) => {
6256
+ settle = { resolve: resolve10, reject };
6254
6257
  });
6255
6258
  result.catch(() => void 0);
6256
6259
  const server = createServer((request2, response3) => {
@@ -6274,7 +6277,7 @@ function listenForCallback(expectedState, clientName) {
6274
6277
  clearTimeout(timer);
6275
6278
  failed(new HarnessError("invalid_request", error.code === "EADDRINUSE" ? `Port ${MCP_OAUTH_PORT} is in use. Finish or cancel the other sign-in, or quit the program using that port, then try again.` : "The sign-in listener could not start."));
6276
6279
  });
6277
- const closed = new Promise((resolve9) => server.once("close", resolve9));
6280
+ const closed = new Promise((resolve10) => server.once("close", resolve10));
6278
6281
  const close = () => {
6279
6282
  clearTimeout(timer);
6280
6283
  if (cancelActive === cancel) cancelActive = null;
@@ -6459,7 +6462,7 @@ var UNATTENDED_SCOPE = "plugin-hooks";
6459
6462
  var ConnectedApps = class {
6460
6463
  constructor(options2) {
6461
6464
  this.options = options2;
6462
- this.connections = new Connections(options2.stateDir, options2.secrets, options2.bus, () => options2.env.dataRoot);
6465
+ this.connections = new Connections(options2.stateDir, options2.secrets, options2.bus, () => options2.env.root);
6463
6466
  this.oauth = {
6464
6467
  clientName: options2.env.productName,
6465
6468
  load: (id) => this.connections.oauthLoad(id),
@@ -8479,7 +8482,7 @@ var RunRegistry = class {
8479
8482
  }
8480
8483
  emit("approval.requested", { approvalId, ...payload });
8481
8484
  emit("status.changed", { status: "waiting_for_approval" });
8482
- return new Promise((resolve9) => {
8485
+ return new Promise((resolve10) => {
8483
8486
  const timer = timeoutMs ? setTimeout(() => this.approvals.get(approvalId)?.resolve("denied"), timeoutMs) : null;
8484
8487
  this.approvals.set(approvalId, {
8485
8488
  runId,
@@ -8488,7 +8491,7 @@ var RunRegistry = class {
8488
8491
  this.approvals.delete(approvalId);
8489
8492
  emit("approval.resolved", { approvalId, decision });
8490
8493
  emit("status.changed", { status: waitingStatus() });
8491
- resolve9(decision);
8494
+ resolve10(decision);
8492
8495
  }
8493
8496
  });
8494
8497
  });
@@ -8499,7 +8502,7 @@ var RunRegistry = class {
8499
8502
  if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] });
8500
8503
  emit("question.requested", { questionId, ...payload });
8501
8504
  emit("status.changed", { status: "waiting_for_input" });
8502
- return new Promise((resolve9) => {
8505
+ return new Promise((resolve10) => {
8503
8506
  this.questions.set(questionId, {
8504
8507
  runId,
8505
8508
  input,
@@ -8507,7 +8510,7 @@ var RunRegistry = class {
8507
8510
  this.questions.delete(questionId);
8508
8511
  emit("question.answered", { questionId, ...answer });
8509
8512
  emit("status.changed", { status: waitingStatus() });
8510
- resolve9(answer);
8513
+ resolve10(answer);
8511
8514
  }
8512
8515
  });
8513
8516
  });
@@ -8586,7 +8589,7 @@ var RunRegistry = class {
8586
8589
  }
8587
8590
  /** Interrupts every live run, giving each a moment to stop cleanly. */
8588
8591
  async interruptAll(timeoutMs = 3e3) {
8589
- await Promise.race([Promise.allSettled(this.activeRunIds().map((id) => this.interrupt(id))), new Promise((resolve9) => setTimeout(resolve9, timeoutMs))]);
8592
+ await Promise.race([Promise.allSettled(this.activeRunIds().map((id) => this.interrupt(id))), new Promise((resolve10) => setTimeout(resolve10, timeoutMs))]);
8590
8593
  }
8591
8594
  resolveApproval(runId, approvalId, decision) {
8592
8595
  if (decision !== "approved" && decision !== "denied" && decision !== "native") throw new HarnessError("invalid_request", "Unknown decision.");
@@ -8786,7 +8789,7 @@ ${text6}`);
8786
8789
  async function waitForChildren(root, runId, signal) {
8787
8790
  while (!signal?.aborted) {
8788
8791
  if (settled(root, runId)) return true;
8789
- await new Promise((resolve9) => setTimeout(resolve9, 250));
8792
+ await new Promise((resolve10) => setTimeout(resolve10, 250));
8790
8793
  }
8791
8794
  return false;
8792
8795
  }
@@ -8867,11 +8870,11 @@ var MAX_TEXT = 4e3;
8867
8870
  var clip2 = (text6) => text6.length > MAX_TEXT ? `${text6.slice(0, MAX_TEXT)}\u2026` : text6;
8868
8871
  function dismissable(answer, fallback, options2) {
8869
8872
  if (!options2?.signal && !options2?.timeout) return answer;
8870
- return new Promise((resolve9) => {
8873
+ return new Promise((resolve10) => {
8871
8874
  let timer;
8872
8875
  const done = (value) => {
8873
8876
  if (timer) clearTimeout(timer);
8874
- resolve9(value);
8877
+ resolve10(value);
8875
8878
  };
8876
8879
  if (options2.signal?.aborted) return done(fallback);
8877
8880
  options2.signal?.addEventListener("abort", () => done(fallback), { once: true });
@@ -9564,7 +9567,7 @@ function connectionFromEnv(env = process.env) {
9564
9567
  return { socket, token };
9565
9568
  }
9566
9569
  function request(connection, body2, what, timeoutMs) {
9567
- return new Promise((resolve9, reject) => {
9570
+ return new Promise((resolve10, reject) => {
9568
9571
  const stream = createConnection(connection.socket);
9569
9572
  let buffer = "";
9570
9573
  let settled2 = false;
@@ -9590,7 +9593,7 @@ function request(connection, body2, what, timeoutMs) {
9590
9593
  if (reply.error) {
9591
9594
  const error = typeof reply.error === "string" ? { message: reply.error } : reply.error;
9592
9595
  finish(() => reject(new SocketError(error.code ?? "error", error.message ?? `${what} failed.`)));
9593
- } else finish(() => resolve9(reply.result));
9596
+ } else finish(() => resolve10(reply.result));
9594
9597
  } catch {
9595
9598
  finish(() => reject(new SocketError("invalid_reply", "The harness sent an unreadable reply.")));
9596
9599
  }
@@ -9794,15 +9797,15 @@ var TerminalController = class {
9794
9797
  if (this.disposed) throw new HarnessError("terminal_not_ready", "Reopen the terminal before sending instructions.");
9795
9798
  if (this.activeRun || this.delivery) throw new HarnessError("session_busy", "This terminal has an active task or delivery.");
9796
9799
  const deadline = Date.now() + READY_TIMEOUT_MS;
9797
- while (!this.ready && !this.startupError && !this.disposed && Date.now() < deadline) await new Promise((resolve9) => setTimeout(resolve9, 50));
9800
+ while (!this.ready && !this.startupError && !this.disposed && Date.now() < deadline) await new Promise((resolve10) => setTimeout(resolve10, 50));
9798
9801
  if (this.disposed) throw new HarnessError("terminal_not_ready", "The terminal stopped during initialization.");
9799
9802
  if (this.startupError) throw new HarnessError("terminal_not_ready", this.startupError);
9800
9803
  if (!this.ready) throw new HarnessError("terminal_not_ready", `The native telemetry handshake is missing (${this.startupPhase}). Inspect the terminal sign-in or trust screen.`);
9801
9804
  if (this.dirty) throw new HarnessError("terminal_has_draft", "The terminal contains user input. Submit or clear it before handing back control.");
9802
9805
  let timer;
9803
- const ack = new Promise((resolve9, reject) => {
9806
+ const ack = new Promise((resolve10, reject) => {
9804
9807
  timer = setTimeout(() => reject(new HarnessError("delivery_uncertain", "The native prompt acknowledgement was not received. Inspect the terminal; this instruction will not be resent.")), ACK_TIMEOUT_MS);
9805
- this.delivery = { runId, text: text6, resolve: resolve9, reject };
9808
+ this.delivery = { runId, text: text6, resolve: resolve10, reject };
9806
9809
  });
9807
9810
  void ack.catch(() => {
9808
9811
  });
@@ -9919,7 +9922,7 @@ var TerminalController = class {
9919
9922
 
9920
9923
  // src/terminal/socket.ts
9921
9924
  import { createHash as createHash7 } from "node:crypto";
9922
- import { chmodSync as chmodSync10, mkdirSync as mkdirSync12, rmSync as rmSync8 } from "node:fs";
9925
+ import { chmodSync as chmodSync10, mkdirSync as mkdirSync12, rmSync as rmSync8, statSync as statSync6 } from "node:fs";
9923
9926
  import { createServer as createServer2 } from "node:net";
9924
9927
  import { tmpdir } from "node:os";
9925
9928
  import { dirname as dirname12, join as join37 } from "node:path";
@@ -9930,6 +9933,23 @@ function socketPath(stateDir) {
9930
9933
  if (Buffer.byteLength(path3) <= MAX_SOCKET_PATH) return path3;
9931
9934
  return join37(tmpdir(), `xh-${createHash7("sha256").update(path3).digest("hex").slice(0, 16)}.sock`);
9932
9935
  }
9936
+ function refusedBind(path3, error) {
9937
+ const code = error?.code;
9938
+ if (code !== "EACCES" && code !== "EPERM") return error;
9939
+ let folder;
9940
+ try {
9941
+ const stat = statSync6(dirname12(path3));
9942
+ folder = `${dirname12(path3)} is owned by uid ${stat.uid} with mode ${(stat.mode & 511).toString(8)}`;
9943
+ } catch {
9944
+ folder = `${dirname12(path3)} could not be read`;
9945
+ }
9946
+ const uid = process.getuid?.() ?? "unknown";
9947
+ return new HarnessError(
9948
+ "session_socket_denied",
9949
+ `The session socket ${path3} could not be created (${code}). ${folder}; this process runs as uid ${uid}.`,
9950
+ "Run the harness from your own terminal, as the user that owns its state folder, or choose a state folder you own with --state."
9951
+ );
9952
+ }
9933
9953
  var SessionSocket = class {
9934
9954
  constructor(path3, handlers) {
9935
9955
  this.path = path3;
@@ -10025,13 +10045,13 @@ var SessionSocket = class {
10025
10045
  }
10026
10046
  /** Binds the socket once; later calls wait for the same bind. */
10027
10047
  listen() {
10028
- this.listening ??= new Promise((resolve9, reject) => {
10048
+ this.listening ??= new Promise((resolve10, reject) => {
10029
10049
  mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
10030
10050
  rmSync8(this.path, { force: true });
10031
10051
  const server = createServer2((client) => this.serve(client));
10032
10052
  server.once("error", (error) => {
10033
10053
  this.listening = null;
10034
- reject(error);
10054
+ reject(refusedBind(this.path, error));
10035
10055
  });
10036
10056
  server.listen(this.path, () => {
10037
10057
  try {
@@ -10040,7 +10060,7 @@ var SessionSocket = class {
10040
10060
  log("session socket permissions", error instanceof Error ? error.message : String(error));
10041
10061
  }
10042
10062
  this.server = server;
10043
- resolve9();
10063
+ resolve10();
10044
10064
  });
10045
10065
  });
10046
10066
  return this.listening;
@@ -10051,7 +10071,7 @@ var SessionSocket = class {
10051
10071
  this.server = null;
10052
10072
  this.listening = null;
10053
10073
  for (const client of this.clients) client.destroy();
10054
- if (server) await new Promise((resolve9) => server.close(() => resolve9()));
10074
+ if (server) await new Promise((resolve10) => server.close(() => resolve10()));
10055
10075
  rmSync8(this.path, { force: true });
10056
10076
  }
10057
10077
  };
@@ -10558,7 +10578,7 @@ import { join as join41, resolve as resolve7 } from "node:path";
10558
10578
  import { createInterface } from "node:readline/promises";
10559
10579
 
10560
10580
  // src/plugins/install.ts
10561
- import { existsSync as existsSync15, mkdirSync as mkdirSync15, renameSync as renameSync8, statSync as statSync6 } from "node:fs";
10581
+ import { existsSync as existsSync15, mkdirSync as mkdirSync15, renameSync as renameSync8, statSync as statSync7 } from "node:fs";
10562
10582
  import { homedir as homedir4 } from "node:os";
10563
10583
  import { basename as basename8, isAbsolute as isAbsolute5, join as join40, resolve as resolve6 } from "node:path";
10564
10584
  function normalizeSource(input) {
@@ -10578,7 +10598,7 @@ async function manager(harness) {
10578
10598
  var changed = (harness) => harness.bus.emit({ type: "plugins.changed" });
10579
10599
  async function install2(harness, params) {
10580
10600
  const source2 = normalizeSource(params.source);
10581
- if (isAbsolute5(source2) && !(existsSync15(source2) && statSync6(source2).isDirectory())) throw invalid("That folder does not exist.");
10601
+ if (isAbsolute5(source2) && !(existsSync15(source2) && statSync7(source2).isDirectory())) throw invalid("That folder does not exist.");
10582
10602
  await ensureAdopted(harness);
10583
10603
  const before = new Set((await list2(harness, params)).map((plugin) => plugin.id));
10584
10604
  const { settings, packages } = await manager(harness);
@@ -10888,7 +10908,7 @@ var RpcPeer = class {
10888
10908
  /** Asks the host to perform a named operation. */
10889
10909
  callHost(call, params, timeoutMs = 3e4) {
10890
10910
  const id = `s${++this.nextCallId}`;
10891
- return new Promise((resolve9, reject) => {
10911
+ return new Promise((resolve10, reject) => {
10892
10912
  const timer = setTimeout(() => {
10893
10913
  this.pendingCalls.delete(id);
10894
10914
  reject(new HarnessError("host_timeout", `The host did not answer ${call}.`));
@@ -10896,7 +10916,7 @@ var RpcPeer = class {
10896
10916
  this.pendingCalls.set(id, {
10897
10917
  resolve: (value) => {
10898
10918
  clearTimeout(timer);
10899
- resolve9(value);
10919
+ resolve10(value);
10900
10920
  },
10901
10921
  reject: (error) => {
10902
10922
  clearTimeout(timer);
@@ -10947,7 +10967,7 @@ var RpcPeer = class {
10947
10967
  };
10948
10968
 
10949
10969
  // src/server/methods.ts
10950
- import { readFileSync as readFileSync20, statSync as statSync7 } from "node:fs";
10970
+ import { readFileSync as readFileSync20, statSync as statSync8 } from "node:fs";
10951
10971
  import { basename as basename9 } from "node:path";
10952
10972
 
10953
10973
  // src/memory/graph.ts
@@ -11161,7 +11181,7 @@ function methods(harness) {
11161
11181
  "wiki.sources": () => listSources(wikiDir2()),
11162
11182
  "wiki.ingest": (params) => {
11163
11183
  const path3 = text5(params, "path");
11164
- if (statSync7(path3).size > MAX_SOURCE_BYTES) throw invalid("Sources larger than 50 MB are not imported.");
11184
+ if (statSync8(path3).size > MAX_SOURCE_BYTES) throw invalid("Sources larger than 50 MB are not imported.");
11165
11185
  const source2 = maintenance.ingest(optional2(params, "name") ?? basename9(path3), readFileSync20(path3), optional2(params, "namespace") ?? LIBRARY_NAMESPACE);
11166
11186
  harness.bus.emit({ type: "wiki.changed" });
11167
11187
  return source2;
@@ -11327,7 +11347,7 @@ function attach3(harness, peer) {
11327
11347
  import { spawn as nodeSpawn } from "node:child_process";
11328
11348
  import { createHash as createHash8, randomUUID as randomUUID8 } from "node:crypto";
11329
11349
  import { homedir as homedir6 } from "node:os";
11330
- import { join as join42, resolve as resolve8 } from "node:path";
11350
+ import { isAbsolute as isAbsolute6, join as join42, resolve as resolve8 } from "node:path";
11331
11351
  function standaloneArguments(argv) {
11332
11352
  const command = argv[0];
11333
11353
  if (!command || command.startsWith("--") || command === "-h") return argv;
@@ -11339,14 +11359,19 @@ function parseStandaloneOptions(argv, home = homedir6(), cwd = process.cwd()) {
11339
11359
  dataRoot: join42(root, "data"),
11340
11360
  stateDir: join42(root, "state")
11341
11361
  };
11362
+ const given = /* @__PURE__ */ new Set();
11342
11363
  for (let index = 0; index < argv.length; index += 1) {
11343
11364
  const argument = argv[index];
11344
11365
  if (argument !== "--data" && argument !== "--state") throw new Error(`Unknown standalone option: ${argument}`);
11345
11366
  const value = argv[index + 1];
11346
11367
  if (!value || value.startsWith("--")) throw new Error(`${argument} needs a directory path.`);
11347
11368
  paths[argument === "--data" ? "dataRoot" : "stateDir"] = resolve8(cwd, value);
11369
+ given.add(argument);
11348
11370
  index += 1;
11349
11371
  }
11372
+ if (!isAbsolute6(home) && !(given.has("--data") && given.has("--state"))) {
11373
+ throw new Error(`The home folder "${home}" is not an absolute path, so the default data and state folders cannot be placed. Set HOME to your home folder, or pass --data and --state with absolute folders.`);
11374
+ }
11350
11375
  return paths;
11351
11376
  }
11352
11377
  function processEnvironment(overrides) {
@@ -11451,15 +11476,16 @@ function flags(argv) {
11451
11476
  }
11452
11477
  function serveCommand(argv) {
11453
11478
  const { values: values2, switches } = flags(argv);
11454
- const stateDir = values2.state?.[0];
11455
- if (!stateDir) {
11479
+ const stateArgument = values2.state?.[0];
11480
+ if (!stateArgument) {
11456
11481
  process.stderr.write("serve needs --state <dir>.\n");
11457
11482
  return Promise.resolve(2);
11458
11483
  }
11484
+ const stateDir = resolve9(stateArgument);
11459
11485
  const peer = new RpcPeer(stdioTransport());
11460
11486
  const options2 = {
11461
11487
  stateDir,
11462
- dataRoot: values2.data?.[0] ?? null,
11488
+ dataRoot: values2.data?.[0] ? resolve9(values2.data[0]) : null,
11463
11489
  productName: values2.name?.[0],
11464
11490
  builtins: values2.builtins,
11465
11491
  ...switches.has("host-secrets") ? { secrets: remoteSecrets(peer) } : {},
@@ -11469,12 +11495,12 @@ function serveCommand(argv) {
11469
11495
  const harness = createHarness(options2);
11470
11496
  if (switches.has("host-tools")) harness.sessions.provide(remoteTools(peer));
11471
11497
  attach3(harness, peer);
11472
- return new Promise((resolve9) => {
11498
+ return new Promise((resolve10) => {
11473
11499
  let closing = false;
11474
11500
  const shutdown = () => {
11475
11501
  if (closing) return;
11476
11502
  closing = true;
11477
- void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5e3))]).then(() => resolve9(0));
11503
+ void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5e3))]).then(() => resolve10(0));
11478
11504
  };
11479
11505
  peer.handle("harness.shutdown", () => void setImmediate(shutdown));
11480
11506
  process.stdin.on("end", shutdown);
@@ -202,7 +202,7 @@ async function piExtension_default(pi) {
202
202
  if (command?.type === "interrupt") context?.abort();
203
203
  else if (command?.type === "send") {
204
204
  if (context?.ui.getEditorText().trim()) throw new Error("The terminal contains a user draft.");
205
- pi.sendUserMessage(command.text);
205
+ pi.sendUserMessage(command.text, context?.isIdle() === false ? { deliverAs: "followUp" } : void 0);
206
206
  }
207
207
  }).catch(() => {
208
208
  context?.abort();
@@ -218,7 +218,7 @@ async function piExtension_default(pi) {
218
218
  usage = emptyUsage();
219
219
  reasoningTokens = void 0;
220
220
  costReported = false;
221
- await terminalCall("begin", { text: event.prompt });
221
+ await terminalCall("begin", { text: event.messageType === "user" ? event.prompt : "" });
222
222
  });
223
223
  pi.on("message_end", async (event) => {
224
224
  if (event.message.role !== "assistant") return;
package/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
- "description": "Xingular Harness \u2014 a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
5
+ "description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tanntnny/xingular-harness.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
6
13
  "exports": {
7
14
  ".": "./src/index.ts",
8
15
  "./*": "./src/*"
9
16
  },
10
17
  "bin": {
11
- "xingular-harness": "./dist/cli.js"
18
+ "xingular-harness": "dist/cli.js"
12
19
  },
13
20
  "scripts": {
14
21
  "build": "esbuild src/cli.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/cli.js && esbuild src/terminal/piExtension.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/terminal-pi.js",
@@ -18,7 +25,7 @@
18
25
  "prove": "npm run build && node scripts/prove-standalone.mjs"
19
26
  },
20
27
  "dependencies": {
21
- "@earendil-works/pi-coding-agent": "1.1.0",
28
+ "@earendil-works/pi-coding-agent": "1.2.0",
22
29
  "@modelcontextprotocol/client": "^2.2.0",
23
30
  "@modelcontextprotocol/server": "^2.2.0",
24
31
  "typebox": "^1.3.7",
@@ -32,7 +39,7 @@
32
39
  "vitest": "^3.2.4"
33
40
  },
34
41
  "engines": {
35
- "node": ">=22.13"
42
+ "node": ">=22.19"
36
43
  },
37
44
  "files": [
38
45
  "src",
package/src/cli.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  // xingular-harness wiki <command> Memory and Wiki, from inside a terminal session.
8
8
  // xingular-harness mcp <command> Connected apps, from inside a terminal session.
9
9
  // xingular-harness plugins <command> List, install, switch and trust plugins.
10
+ import { resolve } from 'node:path'
11
+
10
12
  import { log } from './core/bus'
11
13
  import { createHarness, type HarnessOptions } from './harness'
12
14
  import { runPluginsCli, terminalAsk } from './plugins/cli'
@@ -50,15 +52,17 @@ function flags(argv: string[]): { values: Record<string, string[]>; switches: Se
50
52
 
51
53
  function serveCommand(argv: string[]): Promise<number> {
52
54
  const { values, switches } = flags(argv)
53
- const stateDir = values.state?.[0]
54
- if (!stateDir) {
55
+ const stateArgument = values.state?.[0]
56
+ if (!stateArgument) {
55
57
  process.stderr.write('serve needs --state <dir>.\n')
56
58
  return Promise.resolve(2)
57
59
  }
60
+ // The socket path is built from this folder, so a relative one would depend on where the host ran.
61
+ const stateDir = resolve(stateArgument)
58
62
  const peer = new RpcPeer(stdioTransport())
59
63
  const options: HarnessOptions = {
60
64
  stateDir,
61
- dataRoot: values.data?.[0] ?? null,
65
+ dataRoot: values.data?.[0] ? resolve(values.data[0]) : null,
62
66
  productName: values.name?.[0],
63
67
  builtins: values.builtins,
64
68
  ...(switches.has('host-secrets') ? { secrets: remoteSecrets(peer) } : {}),
@@ -0,0 +1,79 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+
7
+ import * as attachments from '../plugins/attachments'
8
+ import { migrateConnectionScopes, rollbackConnectionScopeMigration } from './scopeMigration'
9
+
10
+ const roots: string[] = []
11
+ function fixture(): { root: string; stateDir: string; sessionPath: string } {
12
+ const base = mkdtempSync(join(tmpdir(), 'xingular-scope-migration-'))
13
+ roots.push(base)
14
+ const root = join(base, 'data')
15
+ const stateDir = join(base, 'state')
16
+ mkdirSync(join(root, 'config'), { recursive: true })
17
+ mkdirSync(join(root, 'sessions', 'agent'), { recursive: true })
18
+ mkdirSync(stateDir, { recursive: true })
19
+ const sessionPath = join(root, 'sessions', 'agent', 'thread.json')
20
+ writeFileSync(sessionPath, Buffer.from('{ "session": "keep these bytes\\nexactly" }\n'))
21
+ mkdirSync(join(root, 'agents', 'agent-a'), { recursive: true })
22
+ writeFileSync(join(root, 'agents', 'agent-a', 'agent.md'), Buffer.from('tools: [read, bash, mcp_call_tool]\n'))
23
+ return { root, stateDir, sessionPath }
24
+ }
25
+
26
+ function digest(path: string): string {
27
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
28
+ }
29
+
30
+ afterEach(() => {
31
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
32
+ })
33
+
34
+ describe('connector scope migration', () => {
35
+ it('moves scopes, verifies assignments, keeps session bytes, and restores exact backups', () => {
36
+ const { root, stateDir, sessionPath } = fixture()
37
+ const sessionHash = digest(sessionPath)
38
+ const agentToolsPath = join(root, 'agents', 'agent-a', 'agent.md')
39
+ const agentToolsHash = digest(agentToolsPath)
40
+ const connectionBytes = Buffer.from(`[{"id":"linear","name":"Linear","kind":"remote","enabled":true,"global":true,"agentIds":["agent-a"],"excludedAgentIds":["agent-b"],"projectIds":["project-a"],"excludedProjectIds":["project-b"],"url":"https://mcp.example"}]\n`)
41
+ const oldAttachmentsBytes = Buffer.from('{"version":1,"global":{"on":["existing"],"off":[]},"agents":{},"projects":{},"settings":{"keep":{"x":1}}}\n')
42
+ const connectionsPath = join(stateDir, 'connections.json')
43
+ const attachmentsPath = join(root, 'config', 'plugin-attachments.json')
44
+ writeFileSync(connectionsPath, connectionBytes)
45
+ writeFileSync(attachmentsPath, oldAttachmentsBytes)
46
+
47
+ const result = migrateConnectionScopes(root, stateDir)
48
+ expect(result).toMatchObject({ status: 'complete', connectionCount: 1, scopeAssignmentCount: 5, hadAttachments: true })
49
+ const saved = JSON.parse(readFileSync(connectionsPath, 'utf8'))[0]
50
+ expect(saved).toMatchObject({ id: 'linear', url: 'https://mcp.example' })
51
+ for (const key of ['global', 'agentIds', 'excludedAgentIds', 'projectIds', 'excludedProjectIds']) expect(saved).not.toHaveProperty(key)
52
+
53
+ const migrated = attachments.load(root)
54
+ expect(migrated.global.on).toContain('connected-apps/connection/linear')
55
+ expect(migrated.agents['agent-a'].on).toContain('connected-apps/connection/linear')
56
+ expect(migrated.agents['agent-b'].off).toContain('connected-apps/connection/linear')
57
+ expect(migrated.projects['project-a'].on).toContain('connected-apps/connection/linear')
58
+ expect(migrated.projects['project-b'].off).toContain('connected-apps/connection/linear')
59
+ expect(digest(sessionPath)).toBe(sessionHash)
60
+ expect(digest(agentToolsPath)).toBe(agentToolsHash)
61
+ expect(readFileSync(join(stateDir, 'connections.pre-capability-scope.json'))).toEqual(connectionBytes)
62
+ expect(readFileSync(join(root, 'config', 'plugin-attachments.pre-capability-scope.json'))).toEqual(oldAttachmentsBytes)
63
+
64
+ rollbackConnectionScopeMigration(root, stateDir)
65
+ expect(readFileSync(connectionsPath)).toEqual(connectionBytes)
66
+ expect(readFileSync(attachmentsPath)).toEqual(oldAttachmentsBytes)
67
+ expect(digest(sessionPath)).toBe(sessionHash)
68
+ expect(digest(agentToolsPath)).toBe(agentToolsHash)
69
+ })
70
+
71
+ it('is idempotent after completion without rewriting session data', () => {
72
+ const { root, stateDir, sessionPath } = fixture()
73
+ writeFileSync(join(stateDir, 'connections.json'), '[{"id":"linear","global":false}]\n')
74
+ const before = digest(sessionPath)
75
+ const first = migrateConnectionScopes(root, stateDir)
76
+ expect(migrateConnectionScopes(root, stateDir)).toEqual(first)
77
+ expect(digest(sessionPath)).toBe(before)
78
+ })
79
+ })
@@ -181,7 +181,7 @@ describe('connected apps for a session', () => {
181
181
  const saved = await harness.apps.save({ ...local, command: '/missing/mcp-fixture' })
182
182
  const result = await harness.apps.list(granted)
183
183
  expect(result.tools).toEqual([])
184
- expect(result.errors[0].message).toContain('Settings')
184
+ expect(result.errors[0].message).toContain('Capability → Connectors')
185
185
  expect(harness.apps.connections.find(saved.id).error).toContain('executable and arguments')
186
186
  })
187
187
 
@@ -134,7 +134,7 @@ export class ConnectedApps implements SessionBroker {
134
134
  private brokered: BrokeredTools | null = null
135
135
 
136
136
  constructor(private readonly options: ConnectedAppsOptions) {
137
- this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.dataRoot)
137
+ this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root)
138
138
  this.oauth = {
139
139
  clientName: options.env.productName,
140
140
  load: (id) => this.connections.oauthLoad(id),
@@ -86,7 +86,8 @@ export default async function (pi: ExtensionAPI) {
86
86
  if (command?.type === 'interrupt') context?.abort()
87
87
  else if (command?.type === 'send') {
88
88
  if (context?.ui.getEditorText().trim()) throw new Error('The terminal contains a user draft.')
89
- pi.sendUserMessage(command.text!)
89
+ // Pi 1.2 needs deliverAs while a run is busy; a message sent then waits for that run to finish.
90
+ pi.sendUserMessage(command.text!, context?.isIdle() === false ? { deliverAs: 'followUp' } : undefined)
90
91
  }
91
92
  })
92
93
  .catch(() => {
@@ -104,7 +105,8 @@ export default async function (pi: ExtensionAPI) {
104
105
  usage = emptyUsage()
105
106
  reasoningTokens = undefined
106
107
  costReported = false
107
- await terminalCall('begin', { text: event.prompt })
108
+ // A run a plugin starts with a custom message has no user prompt to record; it still begins a turn.
109
+ await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '' })
108
110
  })
109
111
  pi.on('message_end', async (event) => {
110
112
  if (event.message.role !== 'assistant') return
@@ -0,0 +1,28 @@
1
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { SessionSocket } from './socket'
7
+
8
+ const handlers = { memory: () => '', apps: async () => null }
9
+
10
+ describe('session socket', () => {
11
+ it('names the folder and the user when the operating system refuses to create the socket', async () => {
12
+ // Root can write anywhere, so the refusal cannot be produced there.
13
+ if (process.platform === 'win32' || process.getuid?.() === 0) return
14
+ const base = mkdtempSync(join(tmpdir(), 'sock-'))
15
+ const run = join(base, 'run')
16
+ mkdirSync(run)
17
+ chmodSync(run, 0o500)
18
+ try {
19
+ await expect(new SessionSocket(join(run, 'harness.sock'), handlers).listen()).rejects.toMatchObject({
20
+ code: 'session_socket_denied',
21
+ message: expect.stringMatching(/could not be created \((?:EACCES|EPERM)\)\. .*mode 500; this process runs as uid \d+\./)
22
+ })
23
+ } finally {
24
+ chmodSync(run, 0o700)
25
+ rmSync(base, { recursive: true, force: true })
26
+ }
27
+ })
28
+ })
@@ -4,7 +4,7 @@
4
4
  // connection. Each terminal session holds a random token bound to its caller, its connected-apps
5
5
  // grant, and its controller; the token stops working when the terminal ends.
6
6
  import { createHash } from 'node:crypto'
7
- import { chmodSync, mkdirSync, rmSync } from 'node:fs'
7
+ import { chmodSync, mkdirSync, rmSync, statSync } from 'node:fs'
8
8
  import { createServer, type Server, type Socket } from 'node:net'
9
9
  import { tmpdir } from 'node:os'
10
10
  import { dirname, join } from 'node:path'
@@ -48,6 +48,25 @@ export function socketPath(stateDir: string): string {
48
48
  return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
49
49
  }
50
50
 
51
+ /** Names the folder and the user when the operating system refuses the socket, so the cause shows. */
52
+ function refusedBind(path: string, error: unknown): unknown {
53
+ const code = (error as { code?: unknown } | null)?.code
54
+ if (code !== 'EACCES' && code !== 'EPERM') return error
55
+ let folder: string
56
+ try {
57
+ const stat = statSync(dirname(path))
58
+ folder = `${dirname(path)} is owned by uid ${stat.uid} with mode ${(stat.mode & 0o777).toString(8)}`
59
+ } catch {
60
+ folder = `${dirname(path)} could not be read`
61
+ }
62
+ const uid = process.getuid?.() ?? 'unknown'
63
+ return new HarnessError(
64
+ 'session_socket_denied',
65
+ `The session socket ${path} could not be created (${code}). ${folder}; this process runs as uid ${uid}.`,
66
+ 'Run the harness from your own terminal, as the user that owns its state folder, or choose a state folder you own with --state.'
67
+ )
68
+ }
69
+
51
70
  export class SessionSocket {
52
71
  private server: Server | null = null
53
72
  private listening: Promise<void> | null = null
@@ -155,7 +174,7 @@ export class SessionSocket {
155
174
  const server = createServer((client) => this.serve(client))
156
175
  server.once('error', (error) => {
157
176
  this.listening = null
158
- reject(error)
177
+ reject(refusedBind(this.path, error))
159
178
  })
160
179
  server.listen(this.path, () => {
161
180
  try {
@@ -67,6 +67,11 @@ describe('standalone terminal startup', () => {
67
67
  expect(() => parseStandaloneOptions(['--model', 'unknown'])).toThrow('Unknown standalone option: --model')
68
68
  })
69
69
 
70
+ it('refuses default folders under a home that is not a folder path, and accepts explicit absolute folders', () => {
71
+ expect(() => parseStandaloneOptions([], 'HOME', '/work/project')).toThrow('is not an absolute path')
72
+ expect(parseStandaloneOptions(['--data', '/data', '--state', '/state'], 'HOME', '/work/project')).toEqual({ dataRoot: '/data', stateDir: '/state' })
73
+ })
74
+
70
75
  it('starts Pi in the current directory, creates the first isolated account, and cleans up on exit', async () => {
71
76
  const home = temporaryDirectory('harness-standalone-')
72
77
  const cwd = temporaryDirectory('harness-cwd-')
@@ -3,7 +3,7 @@
3
3
  import { spawn as nodeSpawn } from 'node:child_process'
4
4
  import { createHash, randomUUID } from 'node:crypto'
5
5
  import { homedir } from 'node:os'
6
- import { join, resolve } from 'node:path'
6
+ import { isAbsolute, join, resolve } from 'node:path'
7
7
 
8
8
  import { createHarness, type Harness, type HarnessOptions } from '../harness'
9
9
  import type { TerminalLaunch } from './terminals'
@@ -36,15 +36,23 @@ export function parseStandaloneOptions(argv: string[], home = homedir(), cwd = p
36
36
  stateDir: join(root, 'state')
37
37
  }
38
38
 
39
+ const given = new Set<string>()
39
40
  for (let index = 0; index < argv.length; index += 1) {
40
41
  const argument = argv[index]
41
42
  if (argument !== '--data' && argument !== '--state') throw new Error(`Unknown standalone option: ${argument}`)
42
43
  const value = argv[index + 1]
43
44
  if (!value || value.startsWith('--')) throw new Error(`${argument} needs a directory path.`)
44
45
  paths[argument === '--data' ? 'dataRoot' : 'stateDir'] = resolve(cwd, value)
46
+ given.add(argument)
45
47
  index += 1
46
48
  }
47
49
 
50
+ // A home that is not a folder path (for example HOME set to the word "HOME") would put the
51
+ // defaults, and the session socket, in whatever folder the command was started from.
52
+ if (!isAbsolute(home) && !(given.has('--data') && given.has('--state'))) {
53
+ throw new Error(`The home folder "${home}" is not an absolute path, so the default data and state folders cannot be placed. Set HOME to your home folder, or pass --data and --state with absolute folders.`)
54
+ }
55
+
48
56
  return paths
49
57
  }
50
58