@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.
- package/dist/hypotenuse.js +269 -71
- package/package.json +1 -1
- package/skills/hypotenuse.md +1 -1
package/dist/hypotenuse.js
CHANGED
|
@@ -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.
|
|
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
|
|
3925
|
+
existsSync as existsSync18,
|
|
3926
3926
|
openSync as openSync3,
|
|
3927
|
-
readFileSync as
|
|
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
|
|
4163
|
-
import { existsSync as
|
|
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
|
|
7313
|
-
import { dirname as
|
|
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(
|
|
7339
|
-
if (
|
|
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
|
|
7627
|
+
readFileSync as readFileSync10,
|
|
7506
7628
|
rmSync as rmSync6,
|
|
7507
7629
|
writeSync
|
|
7508
7630
|
} from "fs";
|
|
7509
|
-
import { dirname as
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
7659
|
-
import { dirname as
|
|
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(
|
|
7903
|
+
mkdirSync10(dirname11(path), { recursive: true, mode: 448 });
|
|
7782
7904
|
}
|
|
7783
|
-
const fresh = path === ":memory:" || !
|
|
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
|
|
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" &&
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
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
|
|
10726
|
+
return createHash3("sha256").update(token).digest("hex");
|
|
10597
10727
|
}
|
|
10598
10728
|
function wasRevoked(stateDir, token) {
|
|
10599
10729
|
const file = revokedMarker(stateDir);
|
|
10600
|
-
if (!
|
|
10730
|
+
if (!existsSync17(file))
|
|
10601
10731
|
return false;
|
|
10602
10732
|
try {
|
|
10603
|
-
return JSON.parse(
|
|
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) =>
|
|
10746
|
-
|
|
10747
|
-
|
|
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
|
-
|
|
10752
|
-
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
11174
|
+
let offset = existsSync18(file) ? statSync5(file).size : 0;
|
|
10977
11175
|
let partial = "";
|
|
10978
11176
|
for (;; ) {
|
|
10979
11177
|
await Bun.sleep(500);
|
|
10980
|
-
if (!
|
|
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 &&
|
|
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
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
|
11948
|
+
import { dirname as dirname14 } from "path";
|
|
11751
11949
|
|
|
11752
11950
|
// src/commands/service.ts
|
|
11753
|
-
import { existsSync as
|
|
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
|
|
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(
|
|
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 || !
|
|
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 &&
|
|
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 (!
|
|
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 &&
|
|
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 (!
|
|
12150
|
-
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
|
|
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 (
|
|
13750
|
-
return
|
|
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
package/skills/hypotenuse.md
CHANGED
|
@@ -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. |
|