@arcadeai/hypotenuse 0.19.0 → 0.20.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.
@@ -343,7 +343,7 @@ import { Command } from "commander";
343
343
  // package.json
344
344
  var package_default = {
345
345
  name: "@arcadeai/hypotenuse",
346
- version: "0.19.0",
346
+ version: "0.20.0",
347
347
  description: "Hypotenuse \u2014 run coding agents on your machine for Linear agent sessions",
348
348
  type: "module",
349
349
  license: "Apache-2.0",
@@ -3922,9 +3922,9 @@ async function socketAnswers(socketPath, timeoutMs = 1500) {
3922
3922
  import { spawn as spawn3 } from "child_process";
3923
3923
  import {
3924
3924
  closeSync as closeSync3,
3925
- existsSync as existsSync17,
3925
+ existsSync as existsSync18,
3926
3926
  openSync as openSync3,
3927
- readFileSync as readFileSync11,
3927
+ readFileSync as readFileSync12,
3928
3928
  readSync,
3929
3929
  rmSync as rmSync9,
3930
3930
  statSync as statSync5
@@ -4159,8 +4159,8 @@ class DaemonLog {
4159
4159
 
4160
4160
  // src/daemon/main.ts
4161
4161
  import { spawn as spawn2 } from "child_process";
4162
- import { createHash as createHash2 } from "crypto";
4163
- import { existsSync as existsSync16, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync5 } from "fs";
4162
+ import { createHash as createHash3 } from "crypto";
4163
+ import { existsSync as existsSync17, readFileSync as readFileSync11, rmSync as rmSync8, writeFileSync as writeFileSync5 } from "fs";
4164
4164
  import { join as join10 } from "path";
4165
4165
 
4166
4166
  // src/acp/host.ts
@@ -7308,9 +7308,131 @@ function registrationHeartbeat() {
7308
7308
  };
7309
7309
  }
7310
7310
 
7311
+ // src/daemon/config-restart.ts
7312
+ class ConfigRestart {
7313
+ deps;
7314
+ timeoutMs;
7315
+ pending = null;
7316
+ generation = 0;
7317
+ takenOver = false;
7318
+ constructor(deps, timeoutMs) {
7319
+ this.deps = deps;
7320
+ this.timeoutMs = timeoutMs;
7321
+ }
7322
+ get isPending() {
7323
+ return this.pending !== null;
7324
+ }
7325
+ get isTakenOver() {
7326
+ return this.takenOver;
7327
+ }
7328
+ saved(outcome, path) {
7329
+ if (this.takenOver)
7330
+ return null;
7331
+ if (this.pending !== null) {
7332
+ if (!outcome.needsRestart) {
7333
+ this.callOff(outcome.invalid ? `${path} is not valid now; the running config stays` : "the change that needed it was undone");
7334
+ }
7335
+ return null;
7336
+ }
7337
+ if (!outcome.needsRestart)
7338
+ return null;
7339
+ const problem = this.deps.startProblem();
7340
+ if (problem) {
7341
+ this.deps.log(`config restart not started: the daemon would not start on ${path} as it is now (${problem}); the running config stays`);
7342
+ return null;
7343
+ }
7344
+ const generation = ++this.generation;
7345
+ this.pending = generation;
7346
+ this.deps.pauseClaims();
7347
+ return this.wait(generation, path);
7348
+ }
7349
+ takeOver() {
7350
+ this.takenOver = true;
7351
+ if (this.pending === null)
7352
+ return;
7353
+ this.pending = null;
7354
+ this.deps.log("config restart called off: a stop or restart was asked for");
7355
+ }
7356
+ async wait(generation, path) {
7357
+ const idle = await this.deps.waitIdle(this.timeoutMs);
7358
+ if (!this.current(generation))
7359
+ return;
7360
+ const problem = this.deps.startProblem();
7361
+ if (problem) {
7362
+ this.callOff(`the daemon would not start on ${path} as it is now (${problem}); the running config stays`);
7363
+ return;
7364
+ }
7365
+ if (!idle)
7366
+ await this.deps.stopTurns();
7367
+ if (!this.current(generation))
7368
+ return;
7369
+ this.pending = null;
7370
+ this.deps.restart();
7371
+ }
7372
+ current(generation) {
7373
+ return this.pending === generation && !this.takenOver && !this.deps.exiting();
7374
+ }
7375
+ callOff(reason) {
7376
+ if (this.pending === null)
7377
+ return;
7378
+ this.pending = null;
7379
+ this.deps.resumeClaims();
7380
+ this.deps.log(`config restart called off: ${reason}`);
7381
+ }
7382
+ }
7383
+
7384
+ // src/daemon/config-watch.ts
7385
+ import { createHash as createHash2 } from "crypto";
7386
+ import { existsSync as existsSync13, readFileSync as readFileSync9, watch } from "fs";
7387
+ import { basename as basename3, dirname as dirname8 } from "path";
7388
+ var CONFIG_WATCH_DEBOUNCE_MS = 300;
7389
+ function digest(path) {
7390
+ if (!existsSync13(path))
7391
+ return null;
7392
+ try {
7393
+ return createHash2("sha256").update(readFileSync9(path)).digest("hex");
7394
+ } catch {
7395
+ return null;
7396
+ }
7397
+ }
7398
+ function watchConfigFile(path, onChange, options = {}) {
7399
+ const name = basename3(path);
7400
+ let last = digest(path);
7401
+ let timer = null;
7402
+ let watcher;
7403
+ const settle = () => {
7404
+ timer = null;
7405
+ const now = digest(path);
7406
+ if (now === null || now === last)
7407
+ return;
7408
+ last = now;
7409
+ onChange();
7410
+ };
7411
+ try {
7412
+ watcher = watch(dirname8(path), (_event, file) => {
7413
+ if (file !== null && file !== name)
7414
+ return;
7415
+ if (timer)
7416
+ clearTimeout(timer);
7417
+ timer = setTimeout(settle, options.debounceMs ?? CONFIG_WATCH_DEBOUNCE_MS);
7418
+ });
7419
+ } catch (error) {
7420
+ options.onError?.(error);
7421
+ return null;
7422
+ }
7423
+ watcher.on("error", (error) => options.onError?.(error));
7424
+ return {
7425
+ close() {
7426
+ if (timer)
7427
+ clearTimeout(timer);
7428
+ watcher.close();
7429
+ }
7430
+ };
7431
+ }
7432
+
7311
7433
  // src/daemon/control-socket.ts
7312
- import { chmodSync as chmodSync4, existsSync as existsSync13, mkdirSync as mkdirSync8, rmSync as rmSync5 } from "fs";
7313
- import { dirname as dirname8 } from "path";
7434
+ import { chmodSync as chmodSync4, existsSync as existsSync14, mkdirSync as mkdirSync8, rmSync as rmSync5 } from "fs";
7435
+ import { dirname as dirname9 } from "path";
7314
7436
  var METHOD_NOT_FOUND = -32601;
7315
7437
  var INVALID_PARAMS = -32602;
7316
7438
  var INTERNAL_ERROR = -32603;
@@ -7335,8 +7457,8 @@ class ControlServer {
7335
7457
  this.onError = onError;
7336
7458
  }
7337
7459
  listen() {
7338
- mkdirSync8(dirname8(this.path), { recursive: true, mode: 448 });
7339
- if (existsSync13(this.path))
7460
+ mkdirSync8(dirname9(this.path), { recursive: true, mode: 448 });
7461
+ if (existsSync14(this.path))
7340
7462
  rmSync5(this.path, { force: true });
7341
7463
  const previous = process.umask(127);
7342
7464
  try {
@@ -7502,11 +7624,11 @@ import {
7502
7624
  closeSync as closeSync2,
7503
7625
  mkdirSync as mkdirSync9,
7504
7626
  openSync as openSync2,
7505
- readFileSync as readFileSync9,
7627
+ readFileSync as readFileSync10,
7506
7628
  rmSync as rmSync6,
7507
7629
  writeSync
7508
7630
  } from "fs";
7509
- import { dirname as dirname9 } from "path";
7631
+ import { dirname as dirname10 } from "path";
7510
7632
  var STARTING_WINDOW_MS = 30000;
7511
7633
  function looksLikeDaemon(args) {
7512
7634
  const program = args.findIndex((arg) => {
@@ -7518,7 +7640,7 @@ function looksLikeDaemon(args) {
7518
7640
  function processArgs(pid) {
7519
7641
  try {
7520
7642
  if (process.platform === "linux") {
7521
- return readFileSync9(`/proc/${pid}/cmdline`, "utf8").split("\x00").filter(Boolean);
7643
+ return readFileSync10(`/proc/${pid}/cmdline`, "utf8").split("\x00").filter(Boolean);
7522
7644
  }
7523
7645
  if (process.platform === "win32") {
7524
7646
  const out = Bun.spawnSync([
@@ -7553,7 +7675,7 @@ var realProbe = {
7553
7675
  function readLock(path) {
7554
7676
  let text;
7555
7677
  try {
7556
- text = readFileSync9(path, "utf8");
7678
+ text = readFileSync10(path, "utf8");
7557
7679
  } catch {
7558
7680
  return;
7559
7681
  }
@@ -7577,7 +7699,7 @@ async function inspectLock(path, probe = realProbe, now = Date.now) {
7577
7699
  return now() - record.startedAt < STARTING_WINDOW_MS ? { kind: "starting", record } : { kind: "hung", record };
7578
7700
  }
7579
7701
  function writeExclusive(path, record) {
7580
- mkdirSync9(dirname9(path), { recursive: true, mode: 448 });
7702
+ mkdirSync9(dirname10(path), { recursive: true, mode: 448 });
7581
7703
  let fd;
7582
7704
  try {
7583
7705
  fd = openSync2(path, "wx", 384);
@@ -7655,8 +7777,8 @@ function childArgs(args) {
7655
7777
 
7656
7778
  // src/daemon/state.ts
7657
7779
  import { Database } from "bun:sqlite";
7658
- import { chmodSync as chmodSync5, existsSync as existsSync14, mkdirSync as mkdirSync10 } from "fs";
7659
- import { dirname as dirname10 } from "path";
7780
+ import { chmodSync as chmodSync5, existsSync as existsSync15, mkdirSync as mkdirSync10 } from "fs";
7781
+ import { dirname as dirname11 } from "path";
7660
7782
  var LIVE_LOCAL_STATES = [
7661
7783
  "claimed",
7662
7784
  "running"
@@ -7778,9 +7900,9 @@ class StateDb {
7778
7900
  this.path = path;
7779
7901
  this.now = now;
7780
7902
  if (path !== ":memory:") {
7781
- mkdirSync10(dirname10(path), { recursive: true, mode: 448 });
7903
+ mkdirSync10(dirname11(path), { recursive: true, mode: 448 });
7782
7904
  }
7783
- const fresh = path === ":memory:" || !existsSync14(path);
7905
+ const fresh = path === ":memory:" || !existsSync15(path);
7784
7906
  this.db = new Database(path, { create: true, strict: true });
7785
7907
  if (fresh && path !== ":memory:")
7786
7908
  chmodSync5(path, 384);
@@ -8598,7 +8720,7 @@ function resolvedConfigSecrets(config, env) {
8598
8720
  }
8599
8721
 
8600
8722
  // src/worktree/cleanup.ts
8601
- import { existsSync as existsSync15, readdirSync, rmSync as rmSync7 } from "fs";
8723
+ import { existsSync as existsSync16, readdirSync, rmSync as rmSync7 } from "fs";
8602
8724
  import { join as join9, relative as relative5, resolve as resolve4 } from "path";
8603
8725
 
8604
8726
  // src/worktree/merge-state.ts
@@ -8736,14 +8858,14 @@ class WorktreeCleaner {
8736
8858
  if (this.inUse(wt))
8737
8859
  return { reason: "in use", overridable: [] };
8738
8860
  const reasons = [];
8739
- if (wt.kind === "worktree" && existsSync15(wt.path)) {
8861
+ if (wt.kind === "worktree" && existsSync16(wt.path)) {
8740
8862
  const status = await this.git(["status", "--porcelain", "--", ".", `:(exclude)${KEEP_FILE}`], { cwd: wt.path });
8741
8863
  if (status.code !== 0 || status.stdout.trim())
8742
8864
  reasons.push("uncommitted changes");
8743
8865
  if (await this.hasUnpushed(wt))
8744
8866
  reasons.push("unpushed commits");
8745
8867
  }
8746
- if (existsSync15(join9(wt.path, KEEP_FILE)))
8868
+ if (existsSync16(join9(wt.path, KEEP_FILE)))
8747
8869
  reasons.push("kept by request");
8748
8870
  return { reason: reasons[0] ?? null, overridable: reasons };
8749
8871
  }
@@ -8800,7 +8922,7 @@ class WorktreeCleaner {
8800
8922
  }
8801
8923
  }
8802
8924
  const scratch = join9(root, SCRATCH);
8803
- if (existsSync15(scratch)) {
8925
+ if (existsSync16(scratch)) {
8804
8926
  for (const name of readdirSync(scratch)) {
8805
8927
  const path = join9(scratch, name);
8806
8928
  const id = Number(name);
@@ -8854,7 +8976,7 @@ class WorktreeCleaner {
8854
8976
  if (!options.dryRun)
8855
8977
  this.state.updateWorktree(wt.id, { keptReason: reason });
8856
8978
  };
8857
- if (!existsSync15(wt.path)) {
8979
+ if (!existsSync16(wt.path)) {
8858
8980
  if (!options.dryRun) {
8859
8981
  if (wt.repoPath)
8860
8982
  await this.git(["worktree", "prune"], { cwd: wt.repoPath });
@@ -10368,6 +10490,14 @@ class Supervisor {
10368
10490
  this.link.beatNow();
10369
10491
  return { drained: this.slots.size === 0 && flushed, cancelled };
10370
10492
  }
10493
+ pauseClaims() {
10494
+ this.draining = true;
10495
+ this.link.beatNow();
10496
+ }
10497
+ resumeClaims() {
10498
+ this.draining = false;
10499
+ this.link.beatNow();
10500
+ }
10371
10501
  async waitIdle(timeoutMs) {
10372
10502
  const deadline = Date.now() + timeoutMs;
10373
10503
  while (this.slots.size > 0) {
@@ -10593,18 +10723,19 @@ function revokedMarker(stateDir) {
10593
10723
  return join10(stateDir, "revoked.json");
10594
10724
  }
10595
10725
  function fingerprint(token) {
10596
- return createHash2("sha256").update(token).digest("hex");
10726
+ return createHash3("sha256").update(token).digest("hex");
10597
10727
  }
10598
10728
  function wasRevoked(stateDir, token) {
10599
10729
  const file = revokedMarker(stateDir);
10600
- if (!existsSync16(file))
10730
+ if (!existsSync17(file))
10601
10731
  return false;
10602
10732
  try {
10603
- return JSON.parse(readFileSync10(file, "utf8")).token === fingerprint(token);
10733
+ return JSON.parse(readFileSync11(file, "utf8")).token === fingerprint(token);
10604
10734
  } catch {
10605
10735
  return false;
10606
10736
  }
10607
10737
  }
10738
+ var CONFIG_RESTART_DRAIN_MS = 10 * 60000;
10608
10739
  function readDaemonConfig(options, env) {
10609
10740
  const path = resolveConfigPath(options.config, env);
10610
10741
  const checked = checkConfig(path, { env, offline: true });
@@ -10677,19 +10808,55 @@ async function runDaemon(options) {
10677
10808
  const machine = realMachine(env);
10678
10809
  const api = new DaemonApi(origin, runner.token, (line) => log.debug(line));
10679
10810
  let exiting = false;
10811
+ let restartScheduled = false;
10812
+ let configHandOff = null;
10680
10813
  let supervisor;
10681
10814
  let control;
10815
+ let watcher = null;
10682
10816
  const teardown = async (drain) => {
10683
10817
  if (drain) {
10684
10818
  await supervisor.drain({ timeoutMs: 1e4, cancel: false });
10685
10819
  } else {
10686
10820
  await supervisor.sink.drain(2000);
10687
10821
  }
10822
+ watcher?.close();
10688
10823
  supervisor.close();
10689
10824
  control.close();
10690
10825
  releaseLock(paths.pidfile);
10691
10826
  state.close();
10692
10827
  };
10828
+ const restartSelf = (fromConfig = false) => {
10829
+ const service = env.HYPOTENUSE_SERVICE === "1";
10830
+ const answer = {
10831
+ restarting: true,
10832
+ mode: service ? "service" : "re-exec"
10833
+ };
10834
+ if (restartScheduled)
10835
+ return answer;
10836
+ restartScheduled = true;
10837
+ const handOff = setTimeout(() => {
10838
+ configHandOff = null;
10839
+ if (exiting)
10840
+ return;
10841
+ exiting = true;
10842
+ log.info(service ? "exiting for the service manager to restart" : "re-executing");
10843
+ teardown(false).then(() => {
10844
+ if (service)
10845
+ process.exit(EXIT_RESTART);
10846
+ const [command, ...prefix] = selfCommand();
10847
+ const child = spawn2(command, [...prefix, ...childArgs(process.argv.slice(2))], {
10848
+ detached: true,
10849
+ stdio: "ignore",
10850
+ env
10851
+ });
10852
+ child.unref();
10853
+ process.exit(0);
10854
+ });
10855
+ }, 50);
10856
+ if (fromConfig)
10857
+ configHandOff = handOff;
10858
+ return answer;
10859
+ };
10693
10860
  const requestExit = (exit) => {
10694
10861
  if (exiting)
10695
10862
  return;
@@ -10725,6 +10892,31 @@ async function runDaemon(options) {
10725
10892
  ...Object.values(credentials.servers).flatMap((s) => s.login ? [s.login.token] : [])
10726
10893
  ]
10727
10894
  });
10895
+ const configRestart = new ConfigRestart({
10896
+ pauseClaims: () => supervisor.pauseClaims(),
10897
+ resumeClaims: () => supervisor.resumeClaims(),
10898
+ waitIdle: (ms) => supervisor.waitIdle(ms),
10899
+ stopTurns: async () => {
10900
+ await supervisor.drain({ timeoutMs: 0, cancel: true });
10901
+ },
10902
+ startProblem: () => {
10903
+ const read = readDaemonConfig(options, env);
10904
+ if (!read.config)
10905
+ return "the config is not valid";
10906
+ const file = credentialsPath(env);
10907
+ try {
10908
+ resolveDaemonRunner(read.config, loadCredentials(file), file);
10909
+ } catch (error) {
10910
+ return error instanceof Error ? error.message : String(error);
10911
+ }
10912
+ return null;
10913
+ },
10914
+ exiting: () => exiting,
10915
+ restart: () => {
10916
+ restartSelf(true);
10917
+ },
10918
+ log: (message) => log.info(message)
10919
+ }, CONFIG_RESTART_DRAIN_MS);
10728
10920
  control = new ControlServer(paths.socket, {
10729
10921
  status: () => supervisor.status({ pid: process.pid, configPath: paths.config }),
10730
10922
  "agents.list": (params) => supervisor.agents(Boolean(params.all)),
@@ -10742,30 +10934,23 @@ async function runDaemon(options) {
10742
10934
  const report = await supervisor.cleaner.run(cleanupParams(params));
10743
10935
  return report;
10744
10936
  },
10745
- drain: (params) => supervisor.drain({
10746
- timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 600000,
10747
- cancel: params.cancel === true
10748
- }),
10937
+ drain: (params) => {
10938
+ configRestart.takeOver();
10939
+ if (configHandOff) {
10940
+ clearTimeout(configHandOff);
10941
+ configHandOff = null;
10942
+ restartScheduled = false;
10943
+ log.info("config restart called off: a stop or restart was asked for");
10944
+ }
10945
+ return supervisor.drain({
10946
+ timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 600000,
10947
+ cancel: params.cancel === true
10948
+ });
10949
+ },
10749
10950
  reload: () => supervisor.reload(),
10750
10951
  restart: () => {
10751
- const service = env.HYPOTENUSE_SERVICE === "1";
10752
- setTimeout(() => {
10753
- exiting = true;
10754
- log.info(service ? "exiting for the service manager to restart" : "re-executing");
10755
- teardown(false).then(() => {
10756
- if (service)
10757
- process.exit(EXIT_RESTART);
10758
- const [command, ...prefix] = selfCommand();
10759
- const child = spawn2(command, [...prefix, ...childArgs(process.argv.slice(2))], {
10760
- detached: true,
10761
- stdio: "ignore",
10762
- env
10763
- });
10764
- child.unref();
10765
- process.exit(0);
10766
- });
10767
- }, 50);
10768
- return { restarting: true, mode: service ? "service" : "re-exec" };
10952
+ configRestart.takeOver();
10953
+ return restartSelf();
10769
10954
  },
10770
10955
  "mcp.hello": (params) => supervisor.broker.hello(params),
10771
10956
  "mcp.call": (params) => supervisor.broker.call(params)
@@ -10801,6 +10986,19 @@ async function runDaemon(options) {
10801
10986
  });
10802
10987
  report({ ready: true, pid: process.pid });
10803
10988
  rmSync8(revokedMarker(paths.stateDir), { force: true });
10989
+ watcher = watchConfigFile(paths.config, () => {
10990
+ if (exiting || restartScheduled || configRestart.isTakenOver)
10991
+ return;
10992
+ log.info(`${paths.config} changed; reloading`);
10993
+ const result = supervisor.reload();
10994
+ const needsRestart = !result.error && result.requiresRestart.length > 0;
10995
+ if (needsRestart && !configRestart.isPending) {
10996
+ log.info("restarting to apply the config change once running turns finish", { keys: result.requiresRestart.join(",") });
10997
+ }
10998
+ configRestart.saved({ needsRestart, invalid: Boolean(result.error) }, paths.config);
10999
+ }, {
11000
+ onError: (error) => log.warn(`not watching ${paths.config} for changes (\`hypotenuse daemon reload\` still applies them): ${error instanceof Error ? error.message : String(error)}`)
11001
+ });
10804
11002
  }
10805
11003
 
10806
11004
  // src/commands/daemon.ts
@@ -10962,22 +11160,22 @@ async function stopDaemon(ctx, options) {
10962
11160
  }
10963
11161
  async function tailLog(file, options) {
10964
11162
  const keep = (line) => !options.session || line.includes(` session=${options.session}`);
10965
- if (!existsSync17(file)) {
11163
+ if (!existsSync18(file)) {
10966
11164
  if (!options.follow)
10967
11165
  throw new Error(`${file} does not exist yet`);
10968
11166
  } else {
10969
- const all = readFileSync11(file, "utf8").split(`
11167
+ const all = readFileSync12(file, "utf8").split(`
10970
11168
  `).filter(Boolean).filter(keep);
10971
11169
  for (const line of all.slice(-options.lines))
10972
11170
  console.log(paintLogLine(line));
10973
11171
  }
10974
11172
  if (!options.follow)
10975
11173
  return;
10976
- let offset = existsSync17(file) ? statSync5(file).size : 0;
11174
+ let offset = existsSync18(file) ? statSync5(file).size : 0;
10977
11175
  let partial = "";
10978
11176
  for (;; ) {
10979
11177
  await Bun.sleep(500);
10980
- if (!existsSync17(file))
11178
+ if (!existsSync18(file))
10981
11179
  continue;
10982
11180
  const size = statSync5(file).size;
10983
11181
  if (size < offset)
@@ -11072,7 +11270,7 @@ function registerDaemonCommands(program) {
11072
11270
  const opts = this.opts();
11073
11271
  const located = locateDaemon(ctx);
11074
11272
  const sessionLog = opts.session && /^\d+$/.test(opts.session) ? sessionLogPath(located.stateDir, Number(opts.session)) : null;
11075
- if (sessionLog && existsSync17(sessionLog)) {
11273
+ if (sessionLog && existsSync18(sessionLog)) {
11076
11274
  await tailLog(sessionLog, {
11077
11275
  lines: Number(opts.lines) || 200,
11078
11276
  follow: Boolean(opts.follow)
@@ -11538,9 +11736,9 @@ function registerCheckUpdateCommand(program) {
11538
11736
  }
11539
11737
 
11540
11738
  // src/commands/config.ts
11541
- import { existsSync as existsSync18, mkdirSync as mkdirSync11, writeFileSync as writeFileSync6 } from "fs";
11739
+ import { existsSync as existsSync19, mkdirSync as mkdirSync11, writeFileSync as writeFileSync6 } from "fs";
11542
11740
  import { hostname as hostname5 } from "os";
11543
- import { dirname as dirname11, resolve as resolve5 } from "path";
11741
+ import { dirname as dirname12, resolve as resolve5 } from "path";
11544
11742
 
11545
11743
  // src/config/template.toml
11546
11744
  var template_default = `#:schema https://www.hypotenuse.cloud/schemas/config-v1.json
@@ -11687,7 +11885,7 @@ function registerConfigCommands(program) {
11687
11885
  await runCommand(this, async (ctx) => {
11688
11886
  const opts = this.opts();
11689
11887
  const path = ctx.configPath;
11690
- if (existsSync18(path) && !opts.force) {
11888
+ if (existsSync19(path) && !opts.force) {
11691
11889
  throw new Error(`${path} already exists; pass --force to overwrite it`);
11692
11890
  }
11693
11891
  const machine = realMachine(ctx.env);
@@ -11704,7 +11902,7 @@ function registerConfigCommands(program) {
11704
11902
  repositories: repositoryBlocks(repos, ctx.env),
11705
11903
  cpus: navigator.hardwareConcurrency || 1
11706
11904
  });
11707
- mkdirSync11(dirname11(resolve5(path)), { recursive: true, mode: 448 });
11905
+ mkdirSync11(dirname12(resolve5(path)), { recursive: true, mode: 448 });
11708
11906
  writeFileSync6(path, text);
11709
11907
  if (!ctx.json)
11710
11908
  console.log(`Wrote ${tildify(path, ctx.env)}.`);
@@ -11745,14 +11943,14 @@ function registerConfigCommands(program) {
11745
11943
  }
11746
11944
 
11747
11945
  // src/commands/doctor.ts
11748
- import { existsSync as existsSync20, statfsSync as statfsSync2, statSync as statSync6 } from "fs";
11946
+ import { existsSync as existsSync21, statfsSync as statfsSync2, statSync as statSync6 } from "fs";
11749
11947
  import { hostname as hostname6 } from "os";
11750
- import { dirname as dirname13 } from "path";
11948
+ import { dirname as dirname14 } from "path";
11751
11949
 
11752
11950
  // src/commands/service.ts
11753
- import { existsSync as existsSync19, mkdirSync as mkdirSync12, rmSync as rmSync10, writeFileSync as writeFileSync7 } from "fs";
11951
+ import { existsSync as existsSync20, mkdirSync as mkdirSync12, rmSync as rmSync10, writeFileSync as writeFileSync7 } from "fs";
11754
11952
  import { homedir as homedir3, userInfo } from "os";
11755
- import { dirname as dirname12, join as join11 } from "path";
11953
+ import { dirname as dirname13, join as join11 } from "path";
11756
11954
 
11757
11955
  // src/daemon/units/launchd.plist
11758
11956
  var launchd_default = `<?xml version="1.0" encoding="UTF-8"?>
@@ -11888,7 +12086,7 @@ function registerServiceCommands(program) {
11888
12086
  console.log(text);
11889
12087
  return;
11890
12088
  }
11891
- mkdirSync12(dirname12(path), { recursive: true });
12089
+ mkdirSync12(dirname13(path), { recursive: true });
11892
12090
  mkdirSync12(paths.stateDir, { recursive: true, mode: 448 });
11893
12091
  writeFileSync7(path, text);
11894
12092
  if (platform === "darwin") {
@@ -11909,7 +12107,7 @@ function registerServiceCommands(program) {
11909
12107
  service.command("uninstall").description("Stop the service and remove its unit file").action(async function() {
11910
12108
  await runCommand(this, async (ctx) => {
11911
12109
  const path = unitPath(process.platform, ctx.env);
11912
- if (!path || !existsSync19(path)) {
12110
+ if (!path || !existsSync20(path)) {
11913
12111
  emit(ctx, { removed: false }, () => "No service is installed.");
11914
12112
  return;
11915
12113
  }
@@ -11927,7 +12125,7 @@ function registerServiceCommands(program) {
11927
12125
  service.command("status").description("Show whether the service is installed and loaded, and the daemon's status").action(async function() {
11928
12126
  await runCommand(this, async (ctx) => {
11929
12127
  const path = unitPath(process.platform, ctx.env);
11930
- const installed = Boolean(path && existsSync19(path));
12128
+ const installed = Boolean(path && existsSync20(path));
11931
12129
  let manager = "not installed";
11932
12130
  if (installed && process.platform === "darwin") {
11933
12131
  manager = run(["launchctl", "print", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`], true).includes("state = running") ? "running" : "loaded, not running";
@@ -12015,7 +12213,7 @@ async function runDoctor(ctx, options) {
12015
12213
  return list.checks;
12016
12214
  }
12017
12215
  let runner = null;
12018
- if (!existsSync20(ctx.credentialsPath)) {
12216
+ if (!existsSync21(ctx.credentialsPath)) {
12019
12217
  list.fail("credentials", `${tildify(ctx.credentialsPath, ctx.env)} does not exist \u2014 \`hypotenuse login\`, then \`hypotenuse runner register\``);
12020
12218
  } else {
12021
12219
  try {
@@ -12078,7 +12276,7 @@ async function runDoctor(ctx, options) {
12078
12276
  list.warn("daemon", "not running \u2014 `hypotenuse daemon start` or `hypotenuse service install`");
12079
12277
  }
12080
12278
  const unit = unitPath(process.platform, ctx.env);
12081
- if (unit && existsSync20(unit))
12279
+ if (unit && existsSync21(unit))
12082
12280
  list.ok("daemon", `service unit ${tildify(unit, ctx.env)}`);
12083
12281
  else if (unit)
12084
12282
  list.warn("daemon", "no service unit \u2014 `hypotenuse service install` starts the daemon at login");
@@ -12146,8 +12344,8 @@ async function runDoctor(ctx, options) {
12146
12344
  `).at(-1) ?? ""}`);
12147
12345
  }
12148
12346
  let probePath = config.sessions.worktree_root;
12149
- while (!existsSync20(probePath) && dirname13(probePath) !== probePath)
12150
- probePath = dirname13(probePath);
12347
+ while (!existsSync21(probePath) && dirname14(probePath) !== probePath)
12348
+ probePath = dirname14(probePath);
12151
12349
  try {
12152
12350
  const fs = statfsSync2(probePath);
12153
12351
  const free = fs.bavail * fs.bsize;
@@ -13737,7 +13935,7 @@ function registerSessionCommands(program) {
13737
13935
  }
13738
13936
 
13739
13937
  // src/commands/skill.ts
13740
- import { existsSync as existsSync21, readFileSync as readFileSync12 } from "fs";
13938
+ import { existsSync as existsSync22, readFileSync as readFileSync13 } from "fs";
13741
13939
  import { join as join13 } from "path";
13742
13940
  var SKILL_FILE = "hypotenuse.md";
13743
13941
  async function readSkill() {
@@ -13746,8 +13944,8 @@ async function readSkill() {
13746
13944
  return embedded.text();
13747
13945
  for (const up of [[".."], ["..", ".."]]) {
13748
13946
  const path = join13(import.meta.dir, ...up, "skills", SKILL_FILE);
13749
- if (existsSync21(path))
13750
- return readFileSync12(path, "utf8");
13947
+ if (existsSync22(path))
13948
+ return readFileSync13(path, "utf8");
13751
13949
  }
13752
13950
  throw new Error(`This install has no skills/${SKILL_FILE}; reinstall hypotenuse.`);
13753
13951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadeai/hypotenuse",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Hypotenuse — run coding agents on your machine for Linear agent sessions",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,7 @@ server. It calls the same HTTP actions the dashboard does, as the person who sig
41
41
  | `hypotenuse telemetry exporter list\|view\|add\|edit\|remove\|test` | Where the selected project's OpenTelemetry goes: each exporter is an OTLP/HTTP collector that receives every turn as one trace, at a content level (`metadata`, `content`, or `full` — full sends prompts and source code). `list` and `view <id>` show endpoint, level, signals, sample share, header names (never values), and the last export or error (any member); `add --name <name> --endpoint <https-url> [--header KEY=VALUE...] [--content <level>] [--signals traces,metrics,logs] [--sample 0.5] [--attribute KEY=VALUE...] [--no-agent-native] [--disabled]`; `edit <id>` with the same flags plus `--clear-headers` and `--enable\|--disable` (headers are kept unless given); `remove <id>`; `test <id>` sends a probe and prints each signal's answer, exiting 1 on a failure. Changes and tests are admin-only; header values are never printed. |
42
42
  | `hypotenuse identity list\|link\|unlink` | Links between people in the project's Linear workspace and project members; routing prefers the runners of whoever started a session. `list [--user <id>]` shows each link and how it was made (e-mail match, Linear sign-in, admin); `link --user <id> --linear-user <id> [--name] [--email]` links one (admin); `unlink <id>` removes your own link, or anyone's as an admin. |
43
43
  | `hypotenuse config init\|validate\|schema\|path` | The daemon's TOML config (`-c <path>`, else `HYPOTENUSE_CONFIG`, else `~/.config/hypotenuse/config.toml`). `init [--repo <path>...] [--force]` writes a commented starter filled in from this machine; `validate [--online] [--offline]` checks shape, references, and this machine (git clones, prompt files, agent commands), and with `--online` the server, runner token, project, and team keys — exit 0 valid (warnings allowed), 1 errors, 2 unreadable; `--json` prints `{ valid, errors: [{ path, message, hint? }], warnings }`. `schema` prints the JSON Schema; `path` prints the config, state directory, and credentials file in use. |
44
- | `hypotenuse daemon start\|stop\|restart\|status\|logs\|reload` | The runner daemon. `start` backgrounds it and waits until it is ready (`--foreground` stays attached, as a service manager runs it; `--log-level <level>` overrides `[log] level`, and `debug` logs every agent activity); `stop [--timeout 10m] [--cancel]` lets running turns finish (or stops them) and exits, `--force` signals it without its control socket; `status` exits 3 when it is not running; `logs [-f] [-n 200] [--session <id>]` (`--session` shows that session's agent log: the agent's stderr and ACP trace); `reload` re-reads the config and says what needs a restart. One daemon per machine. |
44
+ | `hypotenuse daemon start\|stop\|restart\|status\|logs\|reload` | The runner daemon. `start` backgrounds it and waits until it is ready (`--foreground` stays attached, as a service manager runs it; `--log-level <level>` overrides `[log] level`, and `debug` logs every agent activity); `stop [--timeout 10m] [--cancel]` lets running turns finish (or stops them) and exits, `--force` signals it without its control socket; `status` exits 3 when it is not running; `logs [-f] [-n 200] [--session <id>]` (`--session` shows that session's agent log: the agent's stderr and ACP trace); `reload` re-reads the config and says what needs a restart — the daemon also watches its config file, reloading on save and restarting itself (after running turns finish) when a change needs it. One daemon per machine. |
45
45
  | `hypotenuse agents list\|kill\|login` | The coding agents this machine runs. `list [--all]` shows the agent sessions the daemon is running (session, issue, role, agent, state, how long, pid, worktree); `kill <session-id\|ENG-123> [--reason]` stops that session's running turn, which says so in Linear (with the reason); both exit 3 when the daemon is not running. `login <agent> [--method <id>]` logs a coding agent in on this machine — a terminal login runs attached, an agent-run one over ACP — then confirms it can open a session (exit 1 if not). |
46
46
  | `hypotenuse worktrees list\|cleanup` | The git worktrees (and scratch directories) the daemon made for its sessions, one per session under `sessions.worktree_root`. `list` shows each one's repository, branch, issue, state, last use, size, and whether it is safe to remove now (not in use, no uncommitted changes, no unpushed commits, no `.hypotenuse-keep` file). `cleanup [--dry-run] [--all] [--older-than 7d] [--force]` removes what the cleanup policy allows and prints every kept one with its reason; `--all` ignores the policy, `--force` overrides every rule but "in use". Both work without a running daemon, from `state.db` and the disk. |
47
47
  | `hypotenuse service install\|uninstall\|status` | Run the daemon as a user service (launchd on macOS, systemd user unit on Linux) with the `PATH` of the installing shell; `install --print` shows the unit without installing it. Windows: prints the foreground command and the Task Scheduler recipe. |