@arcadeai/hypotenuse 0.17.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.17.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",
@@ -752,6 +752,173 @@ function isPreset(name) {
752
752
 
753
753
  // src/config/resolve.ts
754
754
  import { basename } from "path";
755
+ function agentById(config, id) {
756
+ const table = config.agents[id];
757
+ if (table) {
758
+ const base = table.preset ? [...AGENT_PRESETS[table.preset].command] : table.command ?? [];
759
+ return {
760
+ id,
761
+ preset: table.preset ?? null,
762
+ command: [...base, ...table.args],
763
+ env: table.env,
764
+ maxParallel: table.max_parallel
765
+ };
766
+ }
767
+ if (isPreset(id)) {
768
+ return {
769
+ id,
770
+ preset: id,
771
+ command: [...AGENT_PRESETS[id].command],
772
+ env: {},
773
+ maxParallel: undefined
774
+ };
775
+ }
776
+ return;
777
+ }
778
+ function playedRoles(config) {
779
+ return config.runner.roles.flatMap((role) => {
780
+ const table = config.roles[role];
781
+ return table ? [[role, table]] : [];
782
+ });
783
+ }
784
+ function resolveAgents(config) {
785
+ const byId = new Map;
786
+ for (const id of Object.keys(config.agents)) {
787
+ const agent = agentById(config, id);
788
+ if (agent)
789
+ byId.set(id, { ...agent, roles: [] });
790
+ }
791
+ for (const [role, table] of playedRoles(config)) {
792
+ const known = byId.get(table.agent);
793
+ if (known) {
794
+ if (!known.roles.includes(role))
795
+ known.roles.push(role);
796
+ continue;
797
+ }
798
+ const agent = agentById(config, table.agent);
799
+ if (agent)
800
+ byId.set(table.agent, { ...agent, roles: [role] });
801
+ }
802
+ return [...byId.values()];
803
+ }
804
+ function repositoryName(repo, origin) {
805
+ if (repo.name)
806
+ return repo.name;
807
+ const source = origin ?? repo.path;
808
+ return basename(source.replace(/\/+$/, "")) || "repository";
809
+ }
810
+
811
+ // src/acp/presets.ts
812
+ var SESSION_VARIABLES = new Set([
813
+ "HYPOTENUSE_SESSION_ID",
814
+ "HYPOTENUSE_LINEAR_SESSION_ID",
815
+ "HYPOTENUSE_ISSUE",
816
+ "HYPOTENUSE_ISSUE_URL",
817
+ "HYPOTENUSE_BRANCH",
818
+ "HYPOTENUSE_BASE_BRANCH",
819
+ "HYPOTENUSE_ROLE",
820
+ "HYPOTENUSE_SESSION_URL"
821
+ ]);
822
+ function expandVariables(value, env) {
823
+ return value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (_, braced, bare) => env[braced ?? bare] ?? "");
824
+ }
825
+ function sessionVariables(assignment, branch) {
826
+ return {
827
+ HYPOTENUSE_SESSION_ID: String(assignment.agentSessionId),
828
+ HYPOTENUSE_LINEAR_SESSION_ID: assignment.externalSessionId,
829
+ HYPOTENUSE_ISSUE: assignment.issue.identifier ?? "",
830
+ HYPOTENUSE_ISSUE_URL: assignment.issue.url ?? "",
831
+ HYPOTENUSE_BRANCH: branch ?? "",
832
+ HYPOTENUSE_BASE_BRANCH: assignment.repository?.baseBranch ?? "",
833
+ HYPOTENUSE_ROLE: assignment.role,
834
+ HYPOTENUSE_SESSION_URL: assignment.dashboardUrl,
835
+ GIT_TERMINAL_PROMPT: "0"
836
+ };
837
+ }
838
+ function sessionEnvironment(daemonEnv, extra) {
839
+ const env = {};
840
+ for (const [key, value] of Object.entries(daemonEnv)) {
841
+ if (value === undefined)
842
+ continue;
843
+ if (key.startsWith("HYPOTENUSE_") && !SESSION_VARIABLES.has(key))
844
+ continue;
845
+ env[key] = value;
846
+ }
847
+ return Object.assign(env, extra);
848
+ }
849
+ function resolveAgentLaunch(config, agentId, daemonEnv, extra = {}) {
850
+ const agent = resolveAgents(config).find((a) => a.id === agentId);
851
+ if (!agent || !agent.command.length)
852
+ return null;
853
+ const table = config.agents[agentId];
854
+ const env = sessionEnvironment(daemonEnv, {});
855
+ const preset = agent.preset ? AGENT_PRESETS[agent.preset] : null;
856
+ if (preset && "env" in preset && preset.env)
857
+ Object.assign(env, preset.env);
858
+ if (agent.command[0] === "npx")
859
+ env.npm_config_yes = "true";
860
+ for (const [key, value] of Object.entries(agent.env))
861
+ env[key] = expandVariables(value, daemonEnv);
862
+ Object.assign(env, extra);
863
+ return {
864
+ id: agent.id,
865
+ preset: agent.preset,
866
+ command: agent.command,
867
+ env,
868
+ startupTimeoutMs: table?.startup_timeout ?? 60000,
869
+ authMethod: table?.auth_method ?? null
870
+ };
871
+ }
872
+
873
+ // src/config/loader.ts
874
+ import { existsSync, readFileSync } from "fs";
875
+ import { dirname as dirname2 } from "path";
876
+ import { parse, TomlError } from "smol-toml";
877
+
878
+ // src/config/introspect.ts
879
+ function defOf(schema) {
880
+ return schema._zod.def;
881
+ }
882
+ function unwrap(schema) {
883
+ let current = schema;
884
+ for (;; ) {
885
+ const def = defOf(current);
886
+ if (def.innerType && ["optional", "default", "prefault", "nullable", "readonly"].includes(def.type)) {
887
+ current = def.innerType;
888
+ } else if (def.type === "pipe" && def.in) {
889
+ current = def.in;
890
+ } else {
891
+ return current;
892
+ }
893
+ }
894
+ }
895
+ function schemaAt(schema, path) {
896
+ let current = schema;
897
+ for (const segment of path) {
898
+ if (!current)
899
+ return;
900
+ const def = defOf(unwrap(current));
901
+ if (def.type === "object")
902
+ current = def.shape?.[String(segment)];
903
+ else if (def.type === "record")
904
+ current = def.valueType;
905
+ else if (def.type === "array")
906
+ current = def.element;
907
+ else
908
+ return;
909
+ }
910
+ return current;
911
+ }
912
+ function objectKeys(schema) {
913
+ if (!schema)
914
+ return [];
915
+ const def = defOf(unwrap(schema));
916
+ return def.type === "object" ? Object.keys(def.shape ?? {}) : [];
917
+ }
918
+
919
+ // src/config/schema.ts
920
+ import { cpus, hostname } from "os";
921
+ import { z as z9 } from "zod";
755
922
 
756
923
  // ../protocol/src/capabilities.ts
757
924
  import { z as z2 } from "zod";
@@ -1886,175 +2053,6 @@ var WorkAssignment = z8.looseObject({
1886
2053
  var ClaimResponse = z8.looseObject({
1887
2054
  assignment: WorkAssignment.nullable()
1888
2055
  });
1889
- // src/config/resolve.ts
1890
- function agentById(config, id) {
1891
- const table = config.agents[id];
1892
- if (table) {
1893
- const base = table.preset ? [...AGENT_PRESETS[table.preset].command] : table.command ?? [];
1894
- return {
1895
- id,
1896
- preset: table.preset ?? null,
1897
- command: [...base, ...table.args],
1898
- env: table.env,
1899
- maxParallel: table.max_parallel
1900
- };
1901
- }
1902
- if (isPreset(id)) {
1903
- return {
1904
- id,
1905
- preset: id,
1906
- command: [...AGENT_PRESETS[id].command],
1907
- env: {},
1908
- maxParallel: undefined
1909
- };
1910
- }
1911
- return;
1912
- }
1913
- function playedRoles(config) {
1914
- return config.runner.roles.flatMap((role) => {
1915
- const table = config.roles[role];
1916
- return table ? [[role, table]] : [];
1917
- });
1918
- }
1919
- function resolveAgents(config) {
1920
- const byId = new Map;
1921
- for (const id of Object.keys(config.agents)) {
1922
- const agent = agentById(config, id);
1923
- if (agent)
1924
- byId.set(id, { ...agent, roles: [] });
1925
- }
1926
- for (const [role, table] of playedRoles(config)) {
1927
- const known = byId.get(table.agent);
1928
- if (known) {
1929
- if (!known.roles.includes(role))
1930
- known.roles.push(role);
1931
- continue;
1932
- }
1933
- const agent = agentById(config, table.agent);
1934
- if (agent)
1935
- byId.set(table.agent, { ...agent, roles: [role] });
1936
- }
1937
- return [...byId.values()];
1938
- }
1939
- function repositoryName(repo) {
1940
- if (repo.name)
1941
- return repo.name;
1942
- const source = repo.remote ? normalizeRemote(repo.remote) : repo.path;
1943
- return basename(source.replace(/\/+$/, "")) || "repository";
1944
- }
1945
-
1946
- // src/acp/presets.ts
1947
- var SESSION_VARIABLES = new Set([
1948
- "HYPOTENUSE_SESSION_ID",
1949
- "HYPOTENUSE_LINEAR_SESSION_ID",
1950
- "HYPOTENUSE_ISSUE",
1951
- "HYPOTENUSE_ISSUE_URL",
1952
- "HYPOTENUSE_BRANCH",
1953
- "HYPOTENUSE_BASE_BRANCH",
1954
- "HYPOTENUSE_ROLE",
1955
- "HYPOTENUSE_SESSION_URL"
1956
- ]);
1957
- function expandVariables(value, env) {
1958
- return value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (_, braced, bare) => env[braced ?? bare] ?? "");
1959
- }
1960
- function sessionVariables(assignment, branch) {
1961
- return {
1962
- HYPOTENUSE_SESSION_ID: String(assignment.agentSessionId),
1963
- HYPOTENUSE_LINEAR_SESSION_ID: assignment.externalSessionId,
1964
- HYPOTENUSE_ISSUE: assignment.issue.identifier ?? "",
1965
- HYPOTENUSE_ISSUE_URL: assignment.issue.url ?? "",
1966
- HYPOTENUSE_BRANCH: branch ?? "",
1967
- HYPOTENUSE_BASE_BRANCH: assignment.repository?.baseBranch ?? "",
1968
- HYPOTENUSE_ROLE: assignment.role,
1969
- HYPOTENUSE_SESSION_URL: assignment.dashboardUrl,
1970
- GIT_TERMINAL_PROMPT: "0"
1971
- };
1972
- }
1973
- function sessionEnvironment(daemonEnv, extra) {
1974
- const env = {};
1975
- for (const [key, value] of Object.entries(daemonEnv)) {
1976
- if (value === undefined)
1977
- continue;
1978
- if (key.startsWith("HYPOTENUSE_") && !SESSION_VARIABLES.has(key))
1979
- continue;
1980
- env[key] = value;
1981
- }
1982
- return Object.assign(env, extra);
1983
- }
1984
- function resolveAgentLaunch(config, agentId, daemonEnv, extra = {}) {
1985
- const agent = resolveAgents(config).find((a) => a.id === agentId);
1986
- if (!agent || !agent.command.length)
1987
- return null;
1988
- const table = config.agents[agentId];
1989
- const env = sessionEnvironment(daemonEnv, {});
1990
- const preset = agent.preset ? AGENT_PRESETS[agent.preset] : null;
1991
- if (preset && "env" in preset && preset.env)
1992
- Object.assign(env, preset.env);
1993
- if (agent.command[0] === "npx")
1994
- env.npm_config_yes = "true";
1995
- for (const [key, value] of Object.entries(agent.env))
1996
- env[key] = expandVariables(value, daemonEnv);
1997
- Object.assign(env, extra);
1998
- return {
1999
- id: agent.id,
2000
- preset: agent.preset,
2001
- command: agent.command,
2002
- env,
2003
- startupTimeoutMs: table?.startup_timeout ?? 60000,
2004
- authMethod: table?.auth_method ?? null
2005
- };
2006
- }
2007
-
2008
- // src/config/loader.ts
2009
- import { existsSync, readFileSync } from "fs";
2010
- import { dirname as dirname2 } from "path";
2011
- import { parse, TomlError } from "smol-toml";
2012
-
2013
- // src/config/introspect.ts
2014
- function defOf(schema) {
2015
- return schema._zod.def;
2016
- }
2017
- function unwrap(schema) {
2018
- let current = schema;
2019
- for (;; ) {
2020
- const def = defOf(current);
2021
- if (def.innerType && ["optional", "default", "prefault", "nullable", "readonly"].includes(def.type)) {
2022
- current = def.innerType;
2023
- } else if (def.type === "pipe" && def.in) {
2024
- current = def.in;
2025
- } else {
2026
- return current;
2027
- }
2028
- }
2029
- }
2030
- function schemaAt(schema, path) {
2031
- let current = schema;
2032
- for (const segment of path) {
2033
- if (!current)
2034
- return;
2035
- const def = defOf(unwrap(current));
2036
- if (def.type === "object")
2037
- current = def.shape?.[String(segment)];
2038
- else if (def.type === "record")
2039
- current = def.valueType;
2040
- else if (def.type === "array")
2041
- current = def.element;
2042
- else
2043
- return;
2044
- }
2045
- return current;
2046
- }
2047
- function objectKeys(schema) {
2048
- if (!schema)
2049
- return [];
2050
- const def = defOf(unwrap(schema));
2051
- return def.type === "object" ? Object.keys(def.shape ?? {}) : [];
2052
- }
2053
-
2054
- // src/config/schema.ts
2055
- import { cpus, hostname } from "os";
2056
- import { z as z9 } from "zod";
2057
-
2058
2056
  // src/config/durations.ts
2059
2057
  var DURATION_UNITS = {
2060
2058
  ms: 1,
@@ -2252,7 +2250,6 @@ function buildConfigSchema(ctx) {
2252
2250
  const repository = z9.strictObject({
2253
2251
  name: z9.string().trim().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "letters, digits, `.`, `_`, `-`").max(64).optional().describe("Unique; used in worktree paths"),
2254
2252
  path: path().describe("An existing git clone"),
2255
- remote: z9.string().trim().min(1).optional().describe("The repository's remote; must match origin"),
2256
2253
  base_branch: z9.string().trim().min(1).optional().describe("The branch worktrees start from"),
2257
2254
  setup: strings().default([]).describe("Shell commands run in each new worktree"),
2258
2255
  setup_timeout: duration().prefault("10m").describe("Per setup command"),
@@ -2592,11 +2589,11 @@ function originHead(machine, path) {
2592
2589
  return result.code === 0 && ref.startsWith("origin/") ? ref.slice("origin/".length) : null;
2593
2590
  }
2594
2591
  function resolveRepository(machine, repo) {
2595
- const remote = repo.remote ? normalizeRemote(repo.remote) : originOf(machine, repo.path);
2592
+ const remote = originOf(machine, repo.path);
2596
2593
  if (!remote)
2597
2594
  return null;
2598
2595
  return {
2599
- name: repositoryName({ ...repo, remote }),
2596
+ name: repositoryName(repo, remote),
2600
2597
  remote,
2601
2598
  baseBranch: repo.base_branch ?? originHead(machine, repo.path) ?? "main",
2602
2599
  path: repo.path
@@ -3140,35 +3137,7 @@ function validateReferences(config) {
3140
3137
  hint: 'the default denies edit, delete, move, and execute: { default = "allow", edit = "deny", delete = "deny", move = "deny", execute = "deny" }'
3141
3138
  });
3142
3139
  }
3143
- const names = new Map;
3144
- const remotes = new Map;
3145
3140
  config.repositories.forEach((repo, index) => {
3146
- const name = repositoryName(repo);
3147
- if (!repo.name && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
3148
- errors.push({
3149
- path: `repositories[${index}]`,
3150
- message: `"${name}" is not a usable repository name (letters, digits, \`.\`, \`_\`, \`-\`); set name`
3151
- });
3152
- }
3153
- const firstName = names.get(name);
3154
- if (firstName !== undefined) {
3155
- errors.push({
3156
- path: `repositories[${index}].name`,
3157
- message: `"${name}" is already repositories[${firstName}]`
3158
- });
3159
- } else
3160
- names.set(name, index);
3161
- if (repo.remote) {
3162
- const remote = normalizeRemote(repo.remote);
3163
- const first = remotes.get(remote);
3164
- if (first !== undefined) {
3165
- errors.push({
3166
- path: `repositories[${index}].remote`,
3167
- message: `${remote} is already repositories[${first}]`
3168
- });
3169
- } else
3170
- remotes.set(remote, index);
3171
- }
3172
3141
  if (repo.cleanup === "ttl" && config.sessions.cleanup_ttl === undefined) {
3173
3142
  errors.push({
3174
3143
  path: `repositories[${index}].cleanup`,
@@ -3259,6 +3228,8 @@ function validateMachine(config, machine, options = {}) {
3259
3228
  message: `git ${version.join(".")} is older than ${MIN_GIT_VERSION.join(".")}`
3260
3229
  });
3261
3230
  }
3231
+ const origins = new Map;
3232
+ const names = new Map;
3262
3233
  config.repositories.forEach((repo, index) => {
3263
3234
  const at = `repositories[${index}]`;
3264
3235
  if (!machine.isDirectory(repo.path)) {
@@ -3285,21 +3256,37 @@ function validateMachine(config, machine, options = {}) {
3285
3256
  return;
3286
3257
  }
3287
3258
  const origin = originOf(machine, repo.path);
3288
- const configured = repo.remote ? normalizeRemote(repo.remote) : null;
3289
3259
  if (!origin) {
3290
3260
  errors.push({
3291
- path: `${at}.remote`,
3261
+ path: `${at}.path`,
3292
3262
  message: `${home(repo.path)} has no origin remote`
3293
3263
  });
3294
3264
  return;
3295
3265
  }
3296
- if (configured && origin !== configured) {
3266
+ const first = origins.get(origin);
3267
+ if (first !== undefined) {
3297
3268
  errors.push({
3298
- path: `${at}.remote`,
3299
- message: `origin is ${origin}, config says ${configured}`
3269
+ path: `${at}.path`,
3270
+ message: `${home(repo.path)} is another clone of ${origin}, already repositories[${first}]`
3300
3271
  });
3301
3272
  return;
3302
3273
  }
3274
+ origins.set(origin, index);
3275
+ const name = repositoryName(repo, origin);
3276
+ if (!repo.name && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
3277
+ errors.push({
3278
+ path: `${at}`,
3279
+ message: `"${name}" (from origin ${origin}) is not a usable repository name (letters, digits, \`.\`, \`_\`, \`-\`); set name`
3280
+ });
3281
+ }
3282
+ const firstName = names.get(name);
3283
+ if (firstName !== undefined) {
3284
+ errors.push({
3285
+ path: `${at}.name`,
3286
+ message: `"${name}" is already repositories[${firstName}]${repo.name ? "" : ` (from origin ${origin}); set name`}`
3287
+ });
3288
+ } else
3289
+ names.set(name, index);
3303
3290
  const base = repo.base_branch ?? originHead(machine, repo.path) ?? "main";
3304
3291
  if (!options.offline) {
3305
3292
  const heads = machine.git(["ls-remote", "--heads", "origin", base], repo.path);
@@ -3935,9 +3922,9 @@ async function socketAnswers(socketPath, timeoutMs = 1500) {
3935
3922
  import { spawn as spawn3 } from "child_process";
3936
3923
  import {
3937
3924
  closeSync as closeSync3,
3938
- existsSync as existsSync17,
3925
+ existsSync as existsSync18,
3939
3926
  openSync as openSync3,
3940
- readFileSync as readFileSync11,
3927
+ readFileSync as readFileSync12,
3941
3928
  readSync,
3942
3929
  rmSync as rmSync9,
3943
3930
  statSync as statSync5
@@ -4172,8 +4159,8 @@ class DaemonLog {
4172
4159
 
4173
4160
  // src/daemon/main.ts
4174
4161
  import { spawn as spawn2 } from "child_process";
4175
- import { createHash as createHash2 } from "crypto";
4176
- 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";
4177
4164
  import { join as join10 } from "path";
4178
4165
 
4179
4166
  // src/acp/host.ts
@@ -4634,7 +4621,7 @@ function coAuthorEnvironment(env, hooksDir, coAuthor) {
4634
4621
 
4635
4622
  // src/worktree/manager.ts
4636
4623
  import { existsSync as existsSync10, mkdirSync as mkdirSync5, statSync as statSync4 } from "fs";
4637
- import { join as join6 } from "path";
4624
+ import { isAbsolute as isAbsolute5, join as join6, relative as relative4, resolve as resolve2 } from "path";
4638
4625
 
4639
4626
  // src/worktree/branch.ts
4640
4627
  var SLUG_MAX = 40;
@@ -5187,6 +5174,10 @@ ${stderrTail(fetched)}
5187
5174
  return { row: previous, reused: true };
5188
5175
  }
5189
5176
  request.output(`not reusing ${previous.path}: HEAD is ${head.stdout.trim() || "unreadable"}, not ${previous.branch}`);
5177
+ } else {
5178
+ const orphan = await this.ownOrphan(request, repository);
5179
+ if (orphan)
5180
+ return { row: orphan, reused: true };
5190
5181
  }
5191
5182
  if (!this.diskStatus(request.config).enough) {
5192
5183
  if (!cleaned)
@@ -5237,6 +5228,45 @@ ${stderrTail(fetched)}
5237
5228
  return { row: created, reused: false };
5238
5229
  });
5239
5230
  }
5231
+ async ownOrphan(request, repository) {
5232
+ const id = request.assignment.agentSessionId;
5233
+ const root = resolve2(request.config.sessions.worktree_root);
5234
+ const list = await this.git(["worktree", "list", "--porcelain"], {
5235
+ cwd: repository.path
5236
+ });
5237
+ if (list.code !== 0)
5238
+ return null;
5239
+ for (const block of list.stdout.split(`
5240
+
5241
+ `)) {
5242
+ const path = /^worktree (.+)$/m.exec(block)?.[1];
5243
+ const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
5244
+ if (!path || !branch || !existsSync10(path))
5245
+ continue;
5246
+ const rel = relative4(root, path);
5247
+ if (rel === "" || rel.startsWith("..") || isAbsolute5(rel))
5248
+ continue;
5249
+ if (await this.branchOwner(repository.path, branch) !== id)
5250
+ continue;
5251
+ const known = this.state.worktreeAt(path);
5252
+ if (known && (known.state === "removed" || known.agentSessionId !== id))
5253
+ continue;
5254
+ const row = known ?? this.state.insertWorktree({
5255
+ kind: "worktree",
5256
+ repository: repository.name,
5257
+ repoPath: repository.path,
5258
+ path,
5259
+ branch,
5260
+ baseBranch: repository.baseBranch,
5261
+ agentSessionId: id
5262
+ });
5263
+ this.state.updateWorktree(row.id, { state: "active", touch: true });
5264
+ this.state.linkWorktree(id, row.id);
5265
+ request.output(`reusing ${path} on ${branch}: a previous daemon made it but did not record it`);
5266
+ return this.state.worktree(row.id) ?? row;
5267
+ }
5268
+ return null;
5269
+ }
5240
5270
  reusable(agentSessionId) {
5241
5271
  const session = this.state.session(agentSessionId);
5242
5272
  const row = session?.worktreeId ? this.state.worktree(session.worktreeId) : null;
@@ -5582,7 +5612,7 @@ import {
5582
5612
  rmSync as rmSync4,
5583
5613
  writeFileSync as writeFileSync4
5584
5614
  } from "fs";
5585
- import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute5, join as join7, resolve as resolve2, sep } from "path";
5615
+ import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute6, join as join7, resolve as resolve3, sep } from "path";
5586
5616
 
5587
5617
  class OutsideWorktree extends Error {
5588
5618
  path;
@@ -5641,7 +5671,7 @@ class ConfinedFs {
5641
5671
  const root = this.roots[0];
5642
5672
  if (!root)
5643
5673
  throw new OutsideWorktree(path);
5644
- return isAbsolute5(path) ? resolve2(path) : resolve2(root, path);
5674
+ return isAbsolute6(path) ? resolve3(path) : resolve3(root, path);
5645
5675
  }
5646
5676
  read(path, line, limit) {
5647
5677
  const real = this.resolveExisting(path);
@@ -5935,7 +5965,7 @@ function callText(call) {
5935
5965
  }
5936
5966
 
5937
5967
  // src/acp/mcp-servers.ts
5938
- import { isAbsolute as isAbsolute6 } from "path";
5968
+ import { isAbsolute as isAbsolute7 } from "path";
5939
5969
  var HYPOTENUSE_SERVER_NAME = "hypotenuse";
5940
5970
  var VARIABLE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
5941
5971
 
@@ -5985,7 +6015,7 @@ function turnMcpServers(role, caps, env, hypotenuse) {
5985
6015
  const [command, ...args] = s.command;
5986
6016
  servers.push({
5987
6017
  name: s.name,
5988
- command: isAbsolute6(command) ? command : Bun.which(command, { PATH: env.PATH ?? "" }) ?? command,
6018
+ command: isAbsolute7(command) ? command : Bun.which(command, { PATH: env.PATH ?? "" }) ?? command,
5989
6019
  args,
5990
6020
  env: pairs(s.name, s.env, env)
5991
6021
  });
@@ -7032,11 +7062,10 @@ class Turn {
7032
7062
  return stop === "end_turn" ? this.result("completed") : this.result("failed", `stopReason: ${stop}`);
7033
7063
  }
7034
7064
  repositoryName() {
7065
+ if (this.workspace?.repository)
7066
+ return this.workspace.repository.name;
7035
7067
  const remote = this.ctx.assignment.repository?.remote;
7036
- if (!remote)
7037
- return null;
7038
- const repo = this.ctx.config.repositories.find((r) => r.remote && r.remote.includes(remote));
7039
- return repo ? repositoryName(repo) : remote.split("/").pop() ?? null;
7068
+ return remote ? remote.split("/").pop() ?? null : null;
7040
7069
  }
7041
7070
  async stopped() {
7042
7071
  const reason = this.endReason ?? "stop";
@@ -7279,9 +7308,131 @@ function registrationHeartbeat() {
7279
7308
  };
7280
7309
  }
7281
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
+
7282
7433
  // src/daemon/control-socket.ts
7283
- import { chmodSync as chmodSync4, existsSync as existsSync13, mkdirSync as mkdirSync8, rmSync as rmSync5 } from "fs";
7284
- 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";
7285
7436
  var METHOD_NOT_FOUND = -32601;
7286
7437
  var INVALID_PARAMS = -32602;
7287
7438
  var INTERNAL_ERROR = -32603;
@@ -7306,8 +7457,8 @@ class ControlServer {
7306
7457
  this.onError = onError;
7307
7458
  }
7308
7459
  listen() {
7309
- mkdirSync8(dirname8(this.path), { recursive: true, mode: 448 });
7310
- if (existsSync13(this.path))
7460
+ mkdirSync8(dirname9(this.path), { recursive: true, mode: 448 });
7461
+ if (existsSync14(this.path))
7311
7462
  rmSync5(this.path, { force: true });
7312
7463
  const previous = process.umask(127);
7313
7464
  try {
@@ -7473,11 +7624,11 @@ import {
7473
7624
  closeSync as closeSync2,
7474
7625
  mkdirSync as mkdirSync9,
7475
7626
  openSync as openSync2,
7476
- readFileSync as readFileSync9,
7627
+ readFileSync as readFileSync10,
7477
7628
  rmSync as rmSync6,
7478
7629
  writeSync
7479
7630
  } from "fs";
7480
- import { dirname as dirname9 } from "path";
7631
+ import { dirname as dirname10 } from "path";
7481
7632
  var STARTING_WINDOW_MS = 30000;
7482
7633
  function looksLikeDaemon(args) {
7483
7634
  const program = args.findIndex((arg) => {
@@ -7489,7 +7640,7 @@ function looksLikeDaemon(args) {
7489
7640
  function processArgs(pid) {
7490
7641
  try {
7491
7642
  if (process.platform === "linux") {
7492
- return readFileSync9(`/proc/${pid}/cmdline`, "utf8").split("\x00").filter(Boolean);
7643
+ return readFileSync10(`/proc/${pid}/cmdline`, "utf8").split("\x00").filter(Boolean);
7493
7644
  }
7494
7645
  if (process.platform === "win32") {
7495
7646
  const out = Bun.spawnSync([
@@ -7524,7 +7675,7 @@ var realProbe = {
7524
7675
  function readLock(path) {
7525
7676
  let text;
7526
7677
  try {
7527
- text = readFileSync9(path, "utf8");
7678
+ text = readFileSync10(path, "utf8");
7528
7679
  } catch {
7529
7680
  return;
7530
7681
  }
@@ -7548,7 +7699,7 @@ async function inspectLock(path, probe = realProbe, now = Date.now) {
7548
7699
  return now() - record.startedAt < STARTING_WINDOW_MS ? { kind: "starting", record } : { kind: "hung", record };
7549
7700
  }
7550
7701
  function writeExclusive(path, record) {
7551
- mkdirSync9(dirname9(path), { recursive: true, mode: 448 });
7702
+ mkdirSync9(dirname10(path), { recursive: true, mode: 448 });
7552
7703
  let fd;
7553
7704
  try {
7554
7705
  fd = openSync2(path, "wx", 384);
@@ -7626,8 +7777,8 @@ function childArgs(args) {
7626
7777
 
7627
7778
  // src/daemon/state.ts
7628
7779
  import { Database } from "bun:sqlite";
7629
- import { chmodSync as chmodSync5, existsSync as existsSync14, mkdirSync as mkdirSync10 } from "fs";
7630
- 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";
7631
7782
  var LIVE_LOCAL_STATES = [
7632
7783
  "claimed",
7633
7784
  "running"
@@ -7749,9 +7900,9 @@ class StateDb {
7749
7900
  this.path = path;
7750
7901
  this.now = now;
7751
7902
  if (path !== ":memory:") {
7752
- mkdirSync10(dirname10(path), { recursive: true, mode: 448 });
7903
+ mkdirSync10(dirname11(path), { recursive: true, mode: 448 });
7753
7904
  }
7754
- const fresh = path === ":memory:" || !existsSync14(path);
7905
+ const fresh = path === ":memory:" || !existsSync15(path);
7755
7906
  this.db = new Database(path, { create: true, strict: true });
7756
7907
  if (fresh && path !== ":memory:")
7757
7908
  chmodSync5(path, 384);
@@ -8002,7 +8153,7 @@ class StateDb {
8002
8153
 
8003
8154
  // src/daemon/supervisor.ts
8004
8155
  import { hostname as hostname3 } from "os";
8005
- import { isAbsolute as isAbsolute7 } from "path";
8156
+ import { isAbsolute as isAbsolute8 } from "path";
8006
8157
 
8007
8158
  // src/telemetry/collector.ts
8008
8159
  var SCOPE = { name: "hypotenuse.daemon", version: package_default.version };
@@ -8569,8 +8720,8 @@ function resolvedConfigSecrets(config, env) {
8569
8720
  }
8570
8721
 
8571
8722
  // src/worktree/cleanup.ts
8572
- import { existsSync as existsSync15, readdirSync, rmSync as rmSync7 } from "fs";
8573
- import { join as join9, relative as relative4, resolve as resolve3 } from "path";
8723
+ import { existsSync as existsSync16, readdirSync, rmSync as rmSync7 } from "fs";
8724
+ import { join as join9, relative as relative5, resolve as resolve4 } from "path";
8574
8725
 
8575
8726
  // src/worktree/merge-state.ts
8576
8727
  var MERGE_STATE_TTL_MS = 10 * 60000;
@@ -8707,14 +8858,14 @@ class WorktreeCleaner {
8707
8858
  if (this.inUse(wt))
8708
8859
  return { reason: "in use", overridable: [] };
8709
8860
  const reasons = [];
8710
- if (wt.kind === "worktree" && existsSync15(wt.path)) {
8861
+ if (wt.kind === "worktree" && existsSync16(wt.path)) {
8711
8862
  const status = await this.git(["status", "--porcelain", "--", ".", `:(exclude)${KEEP_FILE}`], { cwd: wt.path });
8712
8863
  if (status.code !== 0 || status.stdout.trim())
8713
8864
  reasons.push("uncommitted changes");
8714
8865
  if (await this.hasUnpushed(wt))
8715
8866
  reasons.push("unpushed commits");
8716
8867
  }
8717
- if (existsSync15(join9(wt.path, KEEP_FILE)))
8868
+ if (existsSync16(join9(wt.path, KEEP_FILE)))
8718
8869
  reasons.push("kept by request");
8719
8870
  return { reason: reasons[0] ?? null, overridable: reasons };
8720
8871
  }
@@ -8733,7 +8884,7 @@ class WorktreeCleaner {
8733
8884
  return result.stdout.trim() !== "";
8734
8885
  }
8735
8886
  async adopt() {
8736
- const root = resolve3(this.config().sessions.worktree_root);
8887
+ const root = resolve4(this.config().sessions.worktree_root);
8737
8888
  const known = new Set(this.state.worktrees({ live: true }).map((w) => w.path));
8738
8889
  let adopted = 0;
8739
8890
  for (const repo of this.options.repositories?.() ?? []) {
@@ -8749,7 +8900,7 @@ class WorktreeCleaner {
8749
8900
  const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
8750
8901
  if (!path || !branch || known.has(path))
8751
8902
  continue;
8752
- const rel = relative4(root, path);
8903
+ const rel = relative5(root, path);
8753
8904
  if (rel.startsWith("..") || rel === "")
8754
8905
  continue;
8755
8906
  const session = await this.git(["config", "--worktree", "--get", "hypotenuse.session"], { cwd: path });
@@ -8771,7 +8922,7 @@ class WorktreeCleaner {
8771
8922
  }
8772
8923
  }
8773
8924
  const scratch = join9(root, SCRATCH);
8774
- if (existsSync15(scratch)) {
8925
+ if (existsSync16(scratch)) {
8775
8926
  for (const name of readdirSync(scratch)) {
8776
8927
  const path = join9(scratch, name);
8777
8928
  const id = Number(name);
@@ -8825,7 +8976,7 @@ class WorktreeCleaner {
8825
8976
  if (!options.dryRun)
8826
8977
  this.state.updateWorktree(wt.id, { keptReason: reason });
8827
8978
  };
8828
- if (!existsSync15(wt.path)) {
8979
+ if (!existsSync16(wt.path)) {
8829
8980
  if (!options.dryRun) {
8830
8981
  if (wt.repoPath)
8831
8982
  await this.git(["worktree", "prune"], { cwd: wt.repoPath });
@@ -10339,6 +10490,14 @@ class Supervisor {
10339
10490
  this.link.beatNow();
10340
10491
  return { drained: this.slots.size === 0 && flushed, cancelled };
10341
10492
  }
10493
+ pauseClaims() {
10494
+ this.draining = true;
10495
+ this.link.beatNow();
10496
+ }
10497
+ resumeClaims() {
10498
+ this.draining = false;
10499
+ this.link.beatNow();
10500
+ }
10342
10501
  async waitIdle(timeoutMs) {
10343
10502
  const deadline = Date.now() + timeoutMs;
10344
10503
  while (this.slots.size > 0) {
@@ -10508,7 +10667,7 @@ function changedKeys(a, b, prefix = "") {
10508
10667
  return JSON.stringify(a) === JSON.stringify(b) ? [] : [prefix];
10509
10668
  }
10510
10669
  function resolveCommand(command, env) {
10511
- if (isAbsolute7(command))
10670
+ if (isAbsolute8(command))
10512
10671
  return command;
10513
10672
  return Bun.which(command, { PATH: env.PATH ?? "" }) ?? command;
10514
10673
  }
@@ -10564,18 +10723,19 @@ function revokedMarker(stateDir) {
10564
10723
  return join10(stateDir, "revoked.json");
10565
10724
  }
10566
10725
  function fingerprint(token) {
10567
- return createHash2("sha256").update(token).digest("hex");
10726
+ return createHash3("sha256").update(token).digest("hex");
10568
10727
  }
10569
10728
  function wasRevoked(stateDir, token) {
10570
10729
  const file = revokedMarker(stateDir);
10571
- if (!existsSync16(file))
10730
+ if (!existsSync17(file))
10572
10731
  return false;
10573
10732
  try {
10574
- return JSON.parse(readFileSync10(file, "utf8")).token === fingerprint(token);
10733
+ return JSON.parse(readFileSync11(file, "utf8")).token === fingerprint(token);
10575
10734
  } catch {
10576
10735
  return false;
10577
10736
  }
10578
10737
  }
10738
+ var CONFIG_RESTART_DRAIN_MS = 10 * 60000;
10579
10739
  function readDaemonConfig(options, env) {
10580
10740
  const path = resolveConfigPath(options.config, env);
10581
10741
  const checked = checkConfig(path, { env, offline: true });
@@ -10648,19 +10808,55 @@ async function runDaemon(options) {
10648
10808
  const machine = realMachine(env);
10649
10809
  const api = new DaemonApi(origin, runner.token, (line) => log.debug(line));
10650
10810
  let exiting = false;
10811
+ let restartScheduled = false;
10812
+ let configHandOff = null;
10651
10813
  let supervisor;
10652
10814
  let control;
10815
+ let watcher = null;
10653
10816
  const teardown = async (drain) => {
10654
10817
  if (drain) {
10655
10818
  await supervisor.drain({ timeoutMs: 1e4, cancel: false });
10656
10819
  } else {
10657
10820
  await supervisor.sink.drain(2000);
10658
10821
  }
10822
+ watcher?.close();
10659
10823
  supervisor.close();
10660
10824
  control.close();
10661
10825
  releaseLock(paths.pidfile);
10662
10826
  state.close();
10663
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
+ };
10664
10860
  const requestExit = (exit) => {
10665
10861
  if (exiting)
10666
10862
  return;
@@ -10696,6 +10892,31 @@ async function runDaemon(options) {
10696
10892
  ...Object.values(credentials.servers).flatMap((s) => s.login ? [s.login.token] : [])
10697
10893
  ]
10698
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);
10699
10920
  control = new ControlServer(paths.socket, {
10700
10921
  status: () => supervisor.status({ pid: process.pid, configPath: paths.config }),
10701
10922
  "agents.list": (params) => supervisor.agents(Boolean(params.all)),
@@ -10713,30 +10934,23 @@ async function runDaemon(options) {
10713
10934
  const report = await supervisor.cleaner.run(cleanupParams(params));
10714
10935
  return report;
10715
10936
  },
10716
- drain: (params) => supervisor.drain({
10717
- timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 600000,
10718
- cancel: params.cancel === true
10719
- }),
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
+ },
10720
10950
  reload: () => supervisor.reload(),
10721
10951
  restart: () => {
10722
- const service = env.HYPOTENUSE_SERVICE === "1";
10723
- setTimeout(() => {
10724
- exiting = true;
10725
- log.info(service ? "exiting for the service manager to restart" : "re-executing");
10726
- teardown(false).then(() => {
10727
- if (service)
10728
- process.exit(EXIT_RESTART);
10729
- const [command, ...prefix] = selfCommand();
10730
- const child = spawn2(command, [...prefix, ...childArgs(process.argv.slice(2))], {
10731
- detached: true,
10732
- stdio: "ignore",
10733
- env
10734
- });
10735
- child.unref();
10736
- process.exit(0);
10737
- });
10738
- }, 50);
10739
- return { restarting: true, mode: service ? "service" : "re-exec" };
10952
+ configRestart.takeOver();
10953
+ return restartSelf();
10740
10954
  },
10741
10955
  "mcp.hello": (params) => supervisor.broker.hello(params),
10742
10956
  "mcp.call": (params) => supervisor.broker.call(params)
@@ -10772,6 +10986,19 @@ async function runDaemon(options) {
10772
10986
  });
10773
10987
  report({ ready: true, pid: process.pid });
10774
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
+ });
10775
11002
  }
10776
11003
 
10777
11004
  // src/commands/daemon.ts
@@ -10933,22 +11160,22 @@ async function stopDaemon(ctx, options) {
10933
11160
  }
10934
11161
  async function tailLog(file, options) {
10935
11162
  const keep = (line) => !options.session || line.includes(` session=${options.session}`);
10936
- if (!existsSync17(file)) {
11163
+ if (!existsSync18(file)) {
10937
11164
  if (!options.follow)
10938
11165
  throw new Error(`${file} does not exist yet`);
10939
11166
  } else {
10940
- const all = readFileSync11(file, "utf8").split(`
11167
+ const all = readFileSync12(file, "utf8").split(`
10941
11168
  `).filter(Boolean).filter(keep);
10942
11169
  for (const line of all.slice(-options.lines))
10943
11170
  console.log(paintLogLine(line));
10944
11171
  }
10945
11172
  if (!options.follow)
10946
11173
  return;
10947
- let offset = existsSync17(file) ? statSync5(file).size : 0;
11174
+ let offset = existsSync18(file) ? statSync5(file).size : 0;
10948
11175
  let partial = "";
10949
11176
  for (;; ) {
10950
11177
  await Bun.sleep(500);
10951
- if (!existsSync17(file))
11178
+ if (!existsSync18(file))
10952
11179
  continue;
10953
11180
  const size = statSync5(file).size;
10954
11181
  if (size < offset)
@@ -11043,7 +11270,7 @@ function registerDaemonCommands(program) {
11043
11270
  const opts = this.opts();
11044
11271
  const located = locateDaemon(ctx);
11045
11272
  const sessionLog = opts.session && /^\d+$/.test(opts.session) ? sessionLogPath(located.stateDir, Number(opts.session)) : null;
11046
- if (sessionLog && existsSync17(sessionLog)) {
11273
+ if (sessionLog && existsSync18(sessionLog)) {
11047
11274
  await tailLog(sessionLog, {
11048
11275
  lines: Number(opts.lines) || 200,
11049
11276
  follow: Boolean(opts.follow)
@@ -11509,13 +11736,14 @@ function registerCheckUpdateCommand(program) {
11509
11736
  }
11510
11737
 
11511
11738
  // src/commands/config.ts
11512
- 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";
11513
11740
  import { hostname as hostname5 } from "os";
11514
- import { dirname as dirname11, resolve as resolve4 } from "path";
11741
+ import { dirname as dirname12, resolve as resolve5 } from "path";
11515
11742
 
11516
11743
  // src/config/template.toml
11517
11744
  var template_default = `#:schema https://www.hypotenuse.cloud/schemas/config-v1.json
11518
- # The hypotenuse daemon's config. Every key: https://www.hypotenuse.cloud/docs/config
11745
+ # The hypotenuse daemon's config. Only what has no default is set; the rest is read from git and
11746
+ # your sign-in. Every key: https://www.hypotenuse.cloud/docs/config
11519
11747
  # Check it with \`hypotenuse config validate\`; apply edits with \`hypotenuse daemon reload\`.
11520
11748
  config_version = 1
11521
11749
 
@@ -11532,8 +11760,9 @@ roles = ["manager", "worker"] # drop "manager" to only implement issues
11532
11760
  # teams = ["ENG"] # only these Linear teams (empty: every team)
11533
11761
  # labels = ["frontend"] # an issue needs one of these labels
11534
11762
 
11535
- [agents.{{agent}}]
11536
- preset = "{{agent}}"
11763
+ # Only to change how an agent is launched; a role can name a preset directly.
11764
+ # [agents.{{agent}}]
11765
+ # preset = "{{agent}}"
11537
11766
  # max_parallel = 2 # jobs using this agent at once
11538
11767
 
11539
11768
  [roles.worker]
@@ -11556,7 +11785,7 @@ agent = "{{agent}}"
11556
11785
  # keepalive_interval = "10m" # must stay under Linear's 30m stale window
11557
11786
 
11558
11787
  [log]
11559
- level = "info"
11788
+ # level = "info"
11560
11789
  `;
11561
11790
 
11562
11791
  // src/commands/config.ts
@@ -11571,41 +11800,31 @@ function detectPreset(machine) {
11571
11800
  }
11572
11801
  return "claude";
11573
11802
  }
11574
- function repositoryBlocks(machine, paths, env) {
11803
+ function repositoryBlocks(paths, env) {
11804
+ const overrides = [
11805
+ '# base_branch = "develop" # default: the branch origin/HEAD names',
11806
+ '# setup = ["bun install --frozen-lockfile"]'
11807
+ ];
11575
11808
  if (paths.length === 0) {
11576
11809
  return [
11577
- "# One table per repository this runner works in (`config init --repo <path>` fills these in):",
11810
+ "# One table per git clone this runner works in (`config init --repo <path>` fills these in):",
11578
11811
  "# [[repositories]]",
11579
- '# name = "web"',
11580
11812
  '# path = "~/code/web"',
11581
- '# remote = "github.com/acme/web"',
11582
- '# base_branch = "main"',
11583
- '# setup = ["bun install --frozen-lockfile"]'
11813
+ ...overrides
11584
11814
  ].join(`
11585
11815
  `);
11586
11816
  }
11587
- return paths.map((path) => {
11588
- const remote = originOf(machine, path);
11589
- const name = repositoryName({
11590
- path,
11591
- remote: remote ?? undefined
11592
- });
11593
- const base = originHead(machine, path) ?? "main";
11594
- return [
11595
- "[[repositories]]",
11596
- `name = ${JSON.stringify(name)}`,
11597
- `path = ${JSON.stringify(tildify(path, env))}`,
11598
- remote ? `remote = ${JSON.stringify(remote)}` : "# remote = (no origin remote found)",
11599
- `base_branch = ${JSON.stringify(base)}`,
11600
- "# setup = []"
11601
- ].join(`
11602
- `);
11603
- }).join(`
11817
+ return paths.map((path) => [
11818
+ "[[repositories]]",
11819
+ `path = ${JSON.stringify(tildify(path, env))}`,
11820
+ ...overrides
11821
+ ].join(`
11822
+ `)).join(`
11604
11823
 
11605
11824
  `);
11606
11825
  }
11607
11826
  function renderTemplate2(input) {
11608
- return template_default.replaceAll("{{server}}", input.server ? `url = ${JSON.stringify(input.server)}` : '# url = "https://api.hypotenuse.cloud"').replaceAll("{{hostname}}", hostname5().replace(/\.local$/i, "")).replaceAll("{{jobs}}", String(defaultMaxParallelJobs(input.cpus))).replaceAll("{{agent}}", input.agent).replaceAll("{{repositories}}", input.repositories);
11827
+ return template_default.replaceAll("{{server}}", `# url = ${JSON.stringify(input.server ?? "https://api.hypotenuse.cloud")} # default: where this machine's runner is registered`).replaceAll("{{hostname}}", hostname5().replace(/\.local$/i, "")).replaceAll("{{jobs}}", String(defaultMaxParallelJobs(input.cpus))).replaceAll("{{agent}}", input.agent).replaceAll("{{repositories}}", input.repositories);
11609
11828
  }
11610
11829
  async function validateFile(ctx, options) {
11611
11830
  const path = ctx.configPath;
@@ -11666,7 +11885,7 @@ function registerConfigCommands(program) {
11666
11885
  await runCommand(this, async (ctx) => {
11667
11886
  const opts = this.opts();
11668
11887
  const path = ctx.configPath;
11669
- if (existsSync18(path) && !opts.force) {
11888
+ if (existsSync19(path) && !opts.force) {
11670
11889
  throw new Error(`${path} already exists; pass --force to overwrite it`);
11671
11890
  }
11672
11891
  const machine = realMachine(ctx.env);
@@ -11680,10 +11899,10 @@ function registerConfigCommands(program) {
11680
11899
  const text = renderTemplate2({
11681
11900
  server,
11682
11901
  agent: detectPreset(machine),
11683
- repositories: repositoryBlocks(machine, repos, ctx.env),
11902
+ repositories: repositoryBlocks(repos, ctx.env),
11684
11903
  cpus: navigator.hardwareConcurrency || 1
11685
11904
  });
11686
- mkdirSync11(dirname11(resolve4(path)), { recursive: true, mode: 448 });
11905
+ mkdirSync11(dirname12(resolve5(path)), { recursive: true, mode: 448 });
11687
11906
  writeFileSync6(path, text);
11688
11907
  if (!ctx.json)
11689
11908
  console.log(`Wrote ${tildify(path, ctx.env)}.`);
@@ -11724,14 +11943,14 @@ function registerConfigCommands(program) {
11724
11943
  }
11725
11944
 
11726
11945
  // src/commands/doctor.ts
11727
- 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";
11728
11947
  import { hostname as hostname6 } from "os";
11729
- import { dirname as dirname13 } from "path";
11948
+ import { dirname as dirname14 } from "path";
11730
11949
 
11731
11950
  // src/commands/service.ts
11732
- 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";
11733
11952
  import { homedir as homedir3, userInfo } from "os";
11734
- import { dirname as dirname12, join as join11 } from "path";
11953
+ import { dirname as dirname13, join as join11 } from "path";
11735
11954
 
11736
11955
  // src/daemon/units/launchd.plist
11737
11956
  var launchd_default = `<?xml version="1.0" encoding="UTF-8"?>
@@ -11867,7 +12086,7 @@ function registerServiceCommands(program) {
11867
12086
  console.log(text);
11868
12087
  return;
11869
12088
  }
11870
- mkdirSync12(dirname12(path), { recursive: true });
12089
+ mkdirSync12(dirname13(path), { recursive: true });
11871
12090
  mkdirSync12(paths.stateDir, { recursive: true, mode: 448 });
11872
12091
  writeFileSync7(path, text);
11873
12092
  if (platform === "darwin") {
@@ -11888,7 +12107,7 @@ function registerServiceCommands(program) {
11888
12107
  service.command("uninstall").description("Stop the service and remove its unit file").action(async function() {
11889
12108
  await runCommand(this, async (ctx) => {
11890
12109
  const path = unitPath(process.platform, ctx.env);
11891
- if (!path || !existsSync19(path)) {
12110
+ if (!path || !existsSync20(path)) {
11892
12111
  emit(ctx, { removed: false }, () => "No service is installed.");
11893
12112
  return;
11894
12113
  }
@@ -11906,7 +12125,7 @@ function registerServiceCommands(program) {
11906
12125
  service.command("status").description("Show whether the service is installed and loaded, and the daemon's status").action(async function() {
11907
12126
  await runCommand(this, async (ctx) => {
11908
12127
  const path = unitPath(process.platform, ctx.env);
11909
- const installed = Boolean(path && existsSync19(path));
12128
+ const installed = Boolean(path && existsSync20(path));
11910
12129
  let manager = "not installed";
11911
12130
  if (installed && process.platform === "darwin") {
11912
12131
  manager = run(["launchctl", "print", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`], true).includes("state = running") ? "running" : "loaded, not running";
@@ -11994,7 +12213,7 @@ async function runDoctor(ctx, options) {
11994
12213
  return list.checks;
11995
12214
  }
11996
12215
  let runner = null;
11997
- if (!existsSync20(ctx.credentialsPath)) {
12216
+ if (!existsSync21(ctx.credentialsPath)) {
11998
12217
  list.fail("credentials", `${tildify(ctx.credentialsPath, ctx.env)} does not exist \u2014 \`hypotenuse login\`, then \`hypotenuse runner register\``);
11999
12218
  } else {
12000
12219
  try {
@@ -12057,7 +12276,7 @@ async function runDoctor(ctx, options) {
12057
12276
  list.warn("daemon", "not running \u2014 `hypotenuse daemon start` or `hypotenuse service install`");
12058
12277
  }
12059
12278
  const unit = unitPath(process.platform, ctx.env);
12060
- if (unit && existsSync20(unit))
12279
+ if (unit && existsSync21(unit))
12061
12280
  list.ok("daemon", `service unit ${tildify(unit, ctx.env)}`);
12062
12281
  else if (unit)
12063
12282
  list.warn("daemon", "no service unit \u2014 `hypotenuse service install` starts the daemon at login");
@@ -12125,8 +12344,8 @@ async function runDoctor(ctx, options) {
12125
12344
  `).at(-1) ?? ""}`);
12126
12345
  }
12127
12346
  let probePath = config.sessions.worktree_root;
12128
- while (!existsSync20(probePath) && dirname13(probePath) !== probePath)
12129
- probePath = dirname13(probePath);
12347
+ while (!existsSync21(probePath) && dirname14(probePath) !== probePath)
12348
+ probePath = dirname14(probePath);
12130
12349
  try {
12131
12350
  const fs = statfsSync2(probePath);
12132
12351
  const free = fs.bavail * fs.bsize;
@@ -13716,7 +13935,7 @@ function registerSessionCommands(program) {
13716
13935
  }
13717
13936
 
13718
13937
  // src/commands/skill.ts
13719
- import { existsSync as existsSync21, readFileSync as readFileSync12 } from "fs";
13938
+ import { existsSync as existsSync22, readFileSync as readFileSync13 } from "fs";
13720
13939
  import { join as join13 } from "path";
13721
13940
  var SKILL_FILE = "hypotenuse.md";
13722
13941
  async function readSkill() {
@@ -13725,8 +13944,8 @@ async function readSkill() {
13725
13944
  return embedded.text();
13726
13945
  for (const up of [[".."], ["..", ".."]]) {
13727
13946
  const path = join13(import.meta.dir, ...up, "skills", SKILL_FILE);
13728
- if (existsSync21(path))
13729
- return readFileSync12(path, "utf8");
13947
+ if (existsSync22(path))
13948
+ return readFileSync13(path, "utf8");
13730
13949
  }
13731
13950
  throw new Error(`This install has no skills/${SKILL_FILE}; reinstall hypotenuse.`);
13732
13951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadeai/hypotenuse",
3
- "version": "0.17.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. |