@arcadeai/hypotenuse 0.16.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/hypotenuse.js +336 -259
  2. package/package.json +1 -1
@@ -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.16.1",
346
+ version: "0.19.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,
@@ -2137,6 +2135,9 @@ function expandPath(raw, baseDir, env = process.env) {
2137
2135
  value = value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (_, braced, bare) => env[braced ?? bare] ?? "");
2138
2136
  return isAbsolute(value) ? resolve(value) : resolve(baseDir, value);
2139
2137
  }
2138
+ function isRemoteSource(source) {
2139
+ return /^https?:\/\//i.test(source.trim());
2140
+ }
2140
2141
  function tildify(path, env = process.env) {
2141
2142
  const home = homeOf(env);
2142
2143
  return path === home ? "~" : path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
@@ -2201,6 +2202,9 @@ function machineContext(baseDir, env = process.env) {
2201
2202
  }
2202
2203
  function buildConfigSchema(ctx) {
2203
2204
  const path = () => z9.string().trim().min(1).transform((value) => expandPath(value, ctx.baseDir, ctx.env));
2205
+ const source = () => z9.string().trim().min(1).refine((value) => !isRemoteSource(value) || URL.canParse(value), {
2206
+ message: "expected a path or an http(s) URL"
2207
+ }).transform((value) => isRemoteSource(value) ? value : expandPath(value, ctx.baseDir, ctx.env));
2204
2208
  const duration = () => z9.string().regex(DURATION_PATTERN, "expected a duration such as 45s, 10m, 2h, or 7d").transform(parseDuration);
2205
2209
  const size = () => z9.string().regex(SIZE_PATTERN, "expected a size such as 500MB or 2GB").transform(parseSize);
2206
2210
  const parallel = () => z9.number().int().min(1).max(32);
@@ -2223,7 +2227,8 @@ function buildConfigSchema(ctx) {
2223
2227
  const role = (fallbackPolicy) => z9.strictObject({
2224
2228
  agent: z9.string().trim().min(1).describe("An [agents.<id>] id or a preset name"),
2225
2229
  prompt: z9.string().min(1).optional().describe("The role's entry prompt template, inline"),
2226
- prompt_file: path().optional().describe("A Markdown prompt template file"),
2230
+ prompt_file: source().optional().describe("A Markdown prompt template file, or a URL of instructions the agent reads with its own tools"),
2231
+ docs: z9.array(source()).default([]).describe("Documents added to the role's instructions: a file's text is included, a URL is linked for the agent to read with its own tools"),
2227
2232
  permission_policy: permissionPolicy(fallbackPolicy),
2228
2233
  max_parallel: parallel().optional().describe("Jobs of this role at once"),
2229
2234
  mode: z9.string().trim().min(1).optional().describe("The ACP session mode to select"),
@@ -2245,7 +2250,6 @@ function buildConfigSchema(ctx) {
2245
2250
  const repository = z9.strictObject({
2246
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"),
2247
2252
  path: path().describe("An existing git clone"),
2248
- remote: z9.string().trim().min(1).optional().describe("The repository's remote; must match origin"),
2249
2253
  base_branch: z9.string().trim().min(1).optional().describe("The branch worktrees start from"),
2250
2254
  setup: strings().default([]).describe("Shell commands run in each new worktree"),
2251
2255
  setup_timeout: duration().prefault("10m").describe("Per setup command"),
@@ -2585,11 +2589,11 @@ function originHead(machine, path) {
2585
2589
  return result.code === 0 && ref.startsWith("origin/") ? ref.slice("origin/".length) : null;
2586
2590
  }
2587
2591
  function resolveRepository(machine, repo) {
2588
- const remote = repo.remote ? normalizeRemote(repo.remote) : originOf(machine, repo.path);
2592
+ const remote = originOf(machine, repo.path);
2589
2593
  if (!remote)
2590
2594
  return null;
2591
2595
  return {
2592
- name: repositoryName({ ...repo, remote }),
2596
+ name: repositoryName(repo, remote),
2593
2597
  remote,
2594
2598
  baseBranch: repo.base_branch ?? originHead(machine, repo.path) ?? "main",
2595
2599
  path: repo.path
@@ -3133,35 +3137,7 @@ function validateReferences(config) {
3133
3137
  hint: 'the default denies edit, delete, move, and execute: { default = "allow", edit = "deny", delete = "deny", move = "deny", execute = "deny" }'
3134
3138
  });
3135
3139
  }
3136
- const names = new Map;
3137
- const remotes = new Map;
3138
3140
  config.repositories.forEach((repo, index) => {
3139
- const name = repositoryName(repo);
3140
- if (!repo.name && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
3141
- errors.push({
3142
- path: `repositories[${index}]`,
3143
- message: `"${name}" is not a usable repository name (letters, digits, \`.\`, \`_\`, \`-\`); set name`
3144
- });
3145
- }
3146
- const firstName = names.get(name);
3147
- if (firstName !== undefined) {
3148
- errors.push({
3149
- path: `repositories[${index}].name`,
3150
- message: `"${name}" is already repositories[${firstName}]`
3151
- });
3152
- } else
3153
- names.set(name, index);
3154
- if (repo.remote) {
3155
- const remote = normalizeRemote(repo.remote);
3156
- const first = remotes.get(remote);
3157
- if (first !== undefined) {
3158
- errors.push({
3159
- path: `repositories[${index}].remote`,
3160
- message: `${remote} is already repositories[${first}]`
3161
- });
3162
- } else
3163
- remotes.set(remote, index);
3164
- }
3165
3141
  if (repo.cleanup === "ttl" && config.sessions.cleanup_ttl === undefined) {
3166
3142
  errors.push({
3167
3143
  path: `repositories[${index}].cleanup`,
@@ -3252,6 +3228,8 @@ function validateMachine(config, machine, options = {}) {
3252
3228
  message: `git ${version.join(".")} is older than ${MIN_GIT_VERSION.join(".")}`
3253
3229
  });
3254
3230
  }
3231
+ const origins = new Map;
3232
+ const names = new Map;
3255
3233
  config.repositories.forEach((repo, index) => {
3256
3234
  const at = `repositories[${index}]`;
3257
3235
  if (!machine.isDirectory(repo.path)) {
@@ -3278,21 +3256,37 @@ function validateMachine(config, machine, options = {}) {
3278
3256
  return;
3279
3257
  }
3280
3258
  const origin = originOf(machine, repo.path);
3281
- const configured = repo.remote ? normalizeRemote(repo.remote) : null;
3282
3259
  if (!origin) {
3283
3260
  errors.push({
3284
- path: `${at}.remote`,
3261
+ path: `${at}.path`,
3285
3262
  message: `${home(repo.path)} has no origin remote`
3286
3263
  });
3287
3264
  return;
3288
3265
  }
3289
- if (configured && origin !== configured) {
3266
+ const first = origins.get(origin);
3267
+ if (first !== undefined) {
3290
3268
  errors.push({
3291
- path: `${at}.remote`,
3292
- message: `origin is ${origin}, config says ${configured}`
3269
+ path: `${at}.path`,
3270
+ message: `${home(repo.path)} is another clone of ${origin}, already repositories[${first}]`
3293
3271
  });
3294
3272
  return;
3295
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);
3296
3290
  const base = repo.base_branch ?? originHead(machine, repo.path) ?? "main";
3297
3291
  if (!options.offline) {
3298
3292
  const heads = machine.git(["ls-remote", "--heads", "origin", base], repo.path);
@@ -3315,7 +3309,7 @@ function validateMachine(config, machine, options = {}) {
3315
3309
  ]) {
3316
3310
  if (!table)
3317
3311
  continue;
3318
- if (table.prompt_file !== undefined) {
3312
+ if (table.prompt_file !== undefined && !isRemoteSource(table.prompt_file)) {
3319
3313
  const text = machine.readFile(table.prompt_file);
3320
3314
  if (text === null) {
3321
3315
  errors.push({
@@ -3326,6 +3320,14 @@ function validateMachine(config, machine, options = {}) {
3326
3320
  checkTemplate(`roles.${role}.prompt_file`, text, errors);
3327
3321
  }
3328
3322
  }
3323
+ table.docs.forEach((doc, index) => {
3324
+ if (isRemoteSource(doc) || machine.readFile(doc) !== null)
3325
+ return;
3326
+ errors.push({
3327
+ path: `roles.${role}.docs[${index}]`,
3328
+ message: `${home(doc)} cannot be read`
3329
+ });
3330
+ });
3329
3331
  table.extra_mcp_servers.forEach((server, index) => {
3330
3332
  const at = `roles.${role}.extra_mcp_servers[${index}]`;
3331
3333
  for (const [key, name] of [
@@ -4619,7 +4621,7 @@ function coAuthorEnvironment(env, hooksDir, coAuthor) {
4619
4621
 
4620
4622
  // src/worktree/manager.ts
4621
4623
  import { existsSync as existsSync10, mkdirSync as mkdirSync5, statSync as statSync4 } from "fs";
4622
- import { join as join6 } from "path";
4624
+ import { isAbsolute as isAbsolute5, join as join6, relative as relative4, resolve as resolve2 } from "path";
4623
4625
 
4624
4626
  // src/worktree/branch.ts
4625
4627
  var SLUG_MAX = 40;
@@ -5172,6 +5174,10 @@ ${stderrTail(fetched)}
5172
5174
  return { row: previous, reused: true };
5173
5175
  }
5174
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 };
5175
5181
  }
5176
5182
  if (!this.diskStatus(request.config).enough) {
5177
5183
  if (!cleaned)
@@ -5222,6 +5228,45 @@ ${stderrTail(fetched)}
5222
5228
  return { row: created, reused: false };
5223
5229
  });
5224
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
+ }
5225
5270
  reusable(agentSessionId) {
5226
5271
  const session = this.state.session(agentSessionId);
5227
5272
  const row = session?.worktreeId ? this.state.worktree(session.worktreeId) : null;
@@ -5567,7 +5612,7 @@ import {
5567
5612
  rmSync as rmSync4,
5568
5613
  writeFileSync as writeFileSync4
5569
5614
  } from "fs";
5570
- 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";
5571
5616
 
5572
5617
  class OutsideWorktree extends Error {
5573
5618
  path;
@@ -5626,7 +5671,7 @@ class ConfinedFs {
5626
5671
  const root = this.roots[0];
5627
5672
  if (!root)
5628
5673
  throw new OutsideWorktree(path);
5629
- return isAbsolute5(path) ? resolve2(path) : resolve2(root, path);
5674
+ return isAbsolute6(path) ? resolve3(path) : resolve3(root, path);
5630
5675
  }
5631
5676
  read(path, line, limit) {
5632
5677
  const real = this.resolveExisting(path);
@@ -5920,7 +5965,7 @@ function callText(call) {
5920
5965
  }
5921
5966
 
5922
5967
  // src/acp/mcp-servers.ts
5923
- import { isAbsolute as isAbsolute6 } from "path";
5968
+ import { isAbsolute as isAbsolute7 } from "path";
5924
5969
  var HYPOTENUSE_SERVER_NAME = "hypotenuse";
5925
5970
  var VARIABLE = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
5926
5971
 
@@ -5970,7 +6015,7 @@ function turnMcpServers(role, caps, env, hypotenuse) {
5970
6015
  const [command, ...args] = s.command;
5971
6016
  servers.push({
5972
6017
  name: s.name,
5973
- command: isAbsolute6(command) ? command : Bun.which(command, { PATH: env.PATH ?? "" }) ?? command,
6018
+ command: isAbsolute7(command) ? command : Bun.which(command, { PATH: env.PATH ?? "" }) ?? command,
5974
6019
  args,
5975
6020
  env: pairs(s.name, s.env, env)
5976
6021
  });
@@ -6209,6 +6254,26 @@ function fenceFor(text) {
6209
6254
  const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
6210
6255
  return "`".repeat(Math.max(3, longest + 1));
6211
6256
  }
6257
+ function remoteInstructions(url) {
6258
+ return `Your instructions for this role are the document at ${url}. Read it with your tools before doing anything else, and follow it. If you cannot read it, say so in your response and stop.`;
6259
+ }
6260
+ function roleDocsSection(docs) {
6261
+ if (docs.length === 0)
6262
+ return "";
6263
+ const links = docs.filter((d) => d.text === null).map((d) => d.source);
6264
+ const files = docs.filter((d) => d.text !== null);
6265
+ return [
6266
+ "These documents are part of your instructions: they say how this team works. Where one disagrees with the instructions above about your tools or what you never do, the instructions above win.",
6267
+ links.length ? `Read each of these with your tools before you begin. If one cannot be read, say so in your response.
6268
+ ${links.map((l) => `- ${l}`).join(`
6269
+ `)}` : null,
6270
+ ...files.map((d) => `<doc source=${attr(d.source)}>
6271
+ ${(d.text ?? "").trim()}
6272
+ </doc>`)
6273
+ ].filter(Boolean).join(`
6274
+
6275
+ `);
6276
+ }
6212
6277
  function reviewSection(review) {
6213
6278
  const issue = review.childIssueIdentifier ?? "the dispatched issue";
6214
6279
  const body = [
@@ -6315,7 +6380,8 @@ ${g.body.trim()}
6315
6380
  `);
6316
6381
  const text = [
6317
6382
  section("hypotenuse", `You are working as the ${assignment.role} agent for ${who}${creator ? ` on behalf of ${creator}` : ""}, in a Linear agent session. You are an AI agent; never claim to be a person. What you say at the end of your turn is posted to the session as your response.${dispatchedBy(assignment)}`),
6318
- section("role-instructions", renderTemplate(template, values, onUnknown)),
6383
+ section("role-instructions", ctx.templateUrl ? remoteInstructions(ctx.templateUrl) : renderTemplate(template, values, onUnknown)),
6384
+ section("role-docs", roleDocsSection(ctx.docs ?? [])),
6319
6385
  section("linear-context", assignment.promptContext),
6320
6386
  section("guidance", guidance),
6321
6387
  section("conversation", conversation(assignment)),
@@ -6908,11 +6974,29 @@ class Turn {
6908
6974
  const conn = this.conn;
6909
6975
  const sessionId = this.acpSessionId;
6910
6976
  let templateText = null;
6911
- if (this.role?.prompt_file) {
6912
- try {
6913
- templateText = readFileSync7(this.role.prompt_file, "utf8");
6914
- } catch (error) {
6915
- throw new TurnFailure(`Runner ${ctx.runnerName} cannot read its ${ctx.assignment.role} prompt file: ${errorMessage(error)}`);
6977
+ let templateUrl = null;
6978
+ const docs = [];
6979
+ if (!this.resumed) {
6980
+ const promptFile = this.role?.prompt_file;
6981
+ if (promptFile && isRemoteSource(promptFile))
6982
+ templateUrl = promptFile;
6983
+ else if (promptFile) {
6984
+ try {
6985
+ templateText = readFileSync7(promptFile, "utf8");
6986
+ } catch (error) {
6987
+ throw new TurnFailure(`Runner ${ctx.runnerName} cannot read its ${ctx.assignment.role} prompt file: ${errorMessage(error)}`);
6988
+ }
6989
+ }
6990
+ for (const doc of this.role?.docs ?? []) {
6991
+ if (isRemoteSource(doc)) {
6992
+ docs.push({ source: doc, text: null });
6993
+ continue;
6994
+ }
6995
+ try {
6996
+ docs.push({ source: tildify(doc), text: readFileSync7(doc, "utf8") });
6997
+ } catch (error) {
6998
+ throw new TurnFailure(`Runner ${ctx.runnerName} cannot read a ${ctx.assignment.role} doc: ${errorMessage(error)}`);
6999
+ }
6916
7000
  }
6917
7001
  }
6918
7002
  const blocks = buildPrompt(ctx.assignment, this.role, {
@@ -6922,6 +7006,8 @@ class Turn {
6922
7006
  runnerName: ctx.runnerName,
6923
7007
  resumed: this.resumed,
6924
7008
  templateText,
7009
+ templateUrl,
7010
+ docs,
6925
7011
  readOnly: this.workspace?.mode === "readonly"
6926
7012
  }, (name) => ctx.log.warn(`the prompt template uses an unknown variable {{${name}}}; it rendered empty`));
6927
7013
  this.promptStartedAt = this.now();
@@ -6976,11 +7062,10 @@ class Turn {
6976
7062
  return stop === "end_turn" ? this.result("completed") : this.result("failed", `stopReason: ${stop}`);
6977
7063
  }
6978
7064
  repositoryName() {
7065
+ if (this.workspace?.repository)
7066
+ return this.workspace.repository.name;
6979
7067
  const remote = this.ctx.assignment.repository?.remote;
6980
- if (!remote)
6981
- return null;
6982
- const repo = this.ctx.config.repositories.find((r) => r.remote && r.remote.includes(remote));
6983
- return repo ? repositoryName(repo) : remote.split("/").pop() ?? null;
7068
+ return remote ? remote.split("/").pop() ?? null : null;
6984
7069
  }
6985
7070
  async stopped() {
6986
7071
  const reason = this.endReason ?? "stop";
@@ -7946,7 +8031,7 @@ class StateDb {
7946
8031
 
7947
8032
  // src/daemon/supervisor.ts
7948
8033
  import { hostname as hostname3 } from "os";
7949
- import { isAbsolute as isAbsolute7 } from "path";
8034
+ import { isAbsolute as isAbsolute8 } from "path";
7950
8035
 
7951
8036
  // src/telemetry/collector.ts
7952
8037
  var SCOPE = { name: "hypotenuse.daemon", version: package_default.version };
@@ -8514,7 +8599,7 @@ function resolvedConfigSecrets(config, env) {
8514
8599
 
8515
8600
  // src/worktree/cleanup.ts
8516
8601
  import { existsSync as existsSync15, readdirSync, rmSync as rmSync7 } from "fs";
8517
- import { join as join9, relative as relative4, resolve as resolve3 } from "path";
8602
+ import { join as join9, relative as relative5, resolve as resolve4 } from "path";
8518
8603
 
8519
8604
  // src/worktree/merge-state.ts
8520
8605
  var MERGE_STATE_TTL_MS = 10 * 60000;
@@ -8677,7 +8762,7 @@ class WorktreeCleaner {
8677
8762
  return result.stdout.trim() !== "";
8678
8763
  }
8679
8764
  async adopt() {
8680
- const root = resolve3(this.config().sessions.worktree_root);
8765
+ const root = resolve4(this.config().sessions.worktree_root);
8681
8766
  const known = new Set(this.state.worktrees({ live: true }).map((w) => w.path));
8682
8767
  let adopted = 0;
8683
8768
  for (const repo of this.options.repositories?.() ?? []) {
@@ -8693,7 +8778,7 @@ class WorktreeCleaner {
8693
8778
  const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
8694
8779
  if (!path || !branch || known.has(path))
8695
8780
  continue;
8696
- const rel = relative4(root, path);
8781
+ const rel = relative5(root, path);
8697
8782
  if (rel.startsWith("..") || rel === "")
8698
8783
  continue;
8699
8784
  const session = await this.git(["config", "--worktree", "--get", "hypotenuse.session"], { cwd: path });
@@ -10452,7 +10537,7 @@ function changedKeys(a, b, prefix = "") {
10452
10537
  return JSON.stringify(a) === JSON.stringify(b) ? [] : [prefix];
10453
10538
  }
10454
10539
  function resolveCommand(command, env) {
10455
- if (isAbsolute7(command))
10540
+ if (isAbsolute8(command))
10456
10541
  return command;
10457
10542
  return Bun.which(command, { PATH: env.PATH ?? "" }) ?? command;
10458
10543
  }
@@ -11455,11 +11540,12 @@ function registerCheckUpdateCommand(program) {
11455
11540
  // src/commands/config.ts
11456
11541
  import { existsSync as existsSync18, mkdirSync as mkdirSync11, writeFileSync as writeFileSync6 } from "fs";
11457
11542
  import { hostname as hostname5 } from "os";
11458
- import { dirname as dirname11, resolve as resolve4 } from "path";
11543
+ import { dirname as dirname11, resolve as resolve5 } from "path";
11459
11544
 
11460
11545
  // src/config/template.toml
11461
11546
  var template_default = `#:schema https://www.hypotenuse.cloud/schemas/config-v1.json
11462
- # The hypotenuse daemon's config. Every key: https://www.hypotenuse.cloud/docs/config
11547
+ # The hypotenuse daemon's config. Only what has no default is set; the rest is read from git and
11548
+ # your sign-in. Every key: https://www.hypotenuse.cloud/docs/config
11463
11549
  # Check it with \`hypotenuse config validate\`; apply edits with \`hypotenuse daemon reload\`.
11464
11550
  config_version = 1
11465
11551
 
@@ -11476,8 +11562,9 @@ roles = ["manager", "worker"] # drop "manager" to only implement issues
11476
11562
  # teams = ["ENG"] # only these Linear teams (empty: every team)
11477
11563
  # labels = ["frontend"] # an issue needs one of these labels
11478
11564
 
11479
- [agents.{{agent}}]
11480
- preset = "{{agent}}"
11565
+ # Only to change how an agent is launched; a role can name a preset directly.
11566
+ # [agents.{{agent}}]
11567
+ # preset = "{{agent}}"
11481
11568
  # max_parallel = 2 # jobs using this agent at once
11482
11569
 
11483
11570
  [roles.worker]
@@ -11500,7 +11587,7 @@ agent = "{{agent}}"
11500
11587
  # keepalive_interval = "10m" # must stay under Linear's 30m stale window
11501
11588
 
11502
11589
  [log]
11503
- level = "info"
11590
+ # level = "info"
11504
11591
  `;
11505
11592
 
11506
11593
  // src/commands/config.ts
@@ -11515,41 +11602,31 @@ function detectPreset(machine) {
11515
11602
  }
11516
11603
  return "claude";
11517
11604
  }
11518
- function repositoryBlocks(machine, paths, env) {
11605
+ function repositoryBlocks(paths, env) {
11606
+ const overrides = [
11607
+ '# base_branch = "develop" # default: the branch origin/HEAD names',
11608
+ '# setup = ["bun install --frozen-lockfile"]'
11609
+ ];
11519
11610
  if (paths.length === 0) {
11520
11611
  return [
11521
- "# One table per repository this runner works in (`config init --repo <path>` fills these in):",
11612
+ "# One table per git clone this runner works in (`config init --repo <path>` fills these in):",
11522
11613
  "# [[repositories]]",
11523
- '# name = "web"',
11524
11614
  '# path = "~/code/web"',
11525
- '# remote = "github.com/acme/web"',
11526
- '# base_branch = "main"',
11527
- '# setup = ["bun install --frozen-lockfile"]'
11615
+ ...overrides
11528
11616
  ].join(`
11529
11617
  `);
11530
11618
  }
11531
- return paths.map((path) => {
11532
- const remote = originOf(machine, path);
11533
- const name = repositoryName({
11534
- path,
11535
- remote: remote ?? undefined
11536
- });
11537
- const base = originHead(machine, path) ?? "main";
11538
- return [
11539
- "[[repositories]]",
11540
- `name = ${JSON.stringify(name)}`,
11541
- `path = ${JSON.stringify(tildify(path, env))}`,
11542
- remote ? `remote = ${JSON.stringify(remote)}` : "# remote = (no origin remote found)",
11543
- `base_branch = ${JSON.stringify(base)}`,
11544
- "# setup = []"
11545
- ].join(`
11546
- `);
11547
- }).join(`
11619
+ return paths.map((path) => [
11620
+ "[[repositories]]",
11621
+ `path = ${JSON.stringify(tildify(path, env))}`,
11622
+ ...overrides
11623
+ ].join(`
11624
+ `)).join(`
11548
11625
 
11549
11626
  `);
11550
11627
  }
11551
11628
  function renderTemplate2(input) {
11552
- 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);
11629
+ 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);
11553
11630
  }
11554
11631
  async function validateFile(ctx, options) {
11555
11632
  const path = ctx.configPath;
@@ -11624,10 +11701,10 @@ function registerConfigCommands(program) {
11624
11701
  const text = renderTemplate2({
11625
11702
  server,
11626
11703
  agent: detectPreset(machine),
11627
- repositories: repositoryBlocks(machine, repos, ctx.env),
11704
+ repositories: repositoryBlocks(repos, ctx.env),
11628
11705
  cpus: navigator.hardwareConcurrency || 1
11629
11706
  });
11630
- mkdirSync11(dirname11(resolve4(path)), { recursive: true, mode: 448 });
11707
+ mkdirSync11(dirname11(resolve5(path)), { recursive: true, mode: 448 });
11631
11708
  writeFileSync6(path, text);
11632
11709
  if (!ctx.json)
11633
11710
  console.log(`Wrote ${tildify(path, ctx.env)}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadeai/hypotenuse",
3
- "version": "0.16.1",
3
+ "version": "0.19.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",