@arcadeai/hypotenuse 0.11.1 → 0.13.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.11.1",
346
+ version: "0.13.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",
@@ -852,14 +852,28 @@ var CANCEL_REASONS = [
852
852
  "dashboard",
853
853
  "delegate_removed",
854
854
  "issue_closed",
855
+ "unassigned",
855
856
  "integration_revoked",
856
857
  "superseded"
857
858
  ];
858
859
  var Cancel = z3.looseObject({
859
860
  workItemId: z3.number().int().positive(),
860
861
  reason: z3.union([z3.enum(CANCEL_REASONS), z3.string().min(1).max(64)]),
861
- by: z3.string().min(1).max(200).optional()
862
+ by: z3.string().min(1).max(200).optional(),
863
+ said: z3.string().min(1).max(2000).optional()
862
864
  });
865
+ var STOP_QUOTE_MAX = 500;
866
+ function stopQuote(said) {
867
+ const text = said?.trim();
868
+ if (!text || /^stop[.!]*$/i.test(text))
869
+ return "";
870
+ const cut = text.length > STOP_QUOTE_MAX ? `${text.slice(0, STOP_QUOTE_MAX)}\u2026` : text;
871
+ return `
872
+
873
+ > You said: ${cut.split(`
874
+ `).join(`
875
+ > `)}`;
876
+ }
863
877
  var Answer = z3.looseObject({
864
878
  workItemId: z3.number().int().positive(),
865
879
  activityId: z3.number().int().positive(),
@@ -912,10 +926,14 @@ var SelectMetadata = z5.strictObject({
912
926
  value: z5.string().trim().min(1).max(1024)
913
927
  })).min(1).max(25)
914
928
  });
929
+ var AuthProvider = z5.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/).describe("An agent id, `github`, or a git host");
915
930
  var AuthMetadata = z5.strictObject({
916
- url: z5.url(),
931
+ url: z5.url().optional(),
932
+ provider: AuthProvider.optional(),
917
933
  userId: z5.string().max(255).optional(),
918
934
  providerName: z5.string().max(255).optional()
935
+ }).refine((m) => m.url !== undefined || m.provider !== undefined, {
936
+ message: "an auth elicitation needs a url or a provider"
919
937
  });
920
938
  var ThoughtEvent = z5.strictObject({
921
939
  ...envelope,
@@ -3524,7 +3542,7 @@ function stopActivity(ctx) {
3524
3542
  case "stop":
3525
3543
  return {
3526
3544
  type: "response",
3527
- body: `Stopped at your request. Changes so far are ${where}.`
3545
+ body: `Stopped at your request. Changes so far are ${where}.${stopQuote(ctx.note)}`
3528
3546
  };
3529
3547
  case "kill":
3530
3548
  return {
@@ -4451,7 +4469,7 @@ function formFields(request) {
4451
4469
  };
4452
4470
  });
4453
4471
  }
4454
- function elicitationQuestion(request, agentTitle) {
4472
+ function elicitationQuestion(request, agentTitle, provider = "agent") {
4455
4473
  const message = truncate(request.message.trim() || "The agent has a question.", 60000);
4456
4474
  const cancelled = () => ({ action: "cancel" });
4457
4475
  const declined = () => ({ action: "decline" });
@@ -4468,7 +4486,11 @@ function elicitationQuestion(request, agentTitle) {
4468
4486
  type: "elicitation",
4469
4487
  body: message,
4470
4488
  signal: "auth",
4471
- signalMetadata: { url, providerName: truncate(agentTitle, 255) }
4489
+ signalMetadata: {
4490
+ url,
4491
+ provider,
4492
+ providerName: truncate(agentTitle, 255)
4493
+ }
4472
4494
  } : { type: "elicitation", body: `${message}
4473
4495
 
4474
4496
  ${url}` },
@@ -4705,6 +4727,14 @@ var SILENT_KINDS = new Set([
4705
4727
  "compaction_update",
4706
4728
  "compaction_summary_chunk"
4707
4729
  ]);
4730
+ function responseSignature(who) {
4731
+ const version = who.agentVersion ? ` ${who.agentVersion}` : "";
4732
+ return `
4733
+
4734
+ +++ \u2014 Hypotenuse (${who.role}, ${who.agentTitle})
4735
+ An AI coding agent: \`${who.agentId}\`${version} on runner \`${who.runnerName}\`, delegated through Hypotenuse.
4736
+ +++`;
4737
+ }
4708
4738
 
4709
4739
  class TurnMapper {
4710
4740
  options;
@@ -4868,9 +4898,10 @@ class TurnMapper {
4868
4898
  switch (stopReason) {
4869
4899
  case "end_turn": {
4870
4900
  const last = this.lastActionSummary;
4901
+ const signature = context.signature ?? "";
4871
4902
  events.push({
4872
4903
  type: "response",
4873
- body: truncate(said || (last ? `Done. Last step: ${last}.` : "Done."), 60000)
4904
+ body: `${truncate(said || (last ? `Done. Last step: ${last}.` : "Done."), 60000 - signature.length)}${signature}`
4874
4905
  });
4875
4906
  break;
4876
4907
  }
@@ -5079,26 +5110,101 @@ class PrDetector {
5079
5110
  }
5080
5111
  }
5081
5112
 
5113
+ // src/acp/preflight.ts
5114
+ function providerSlug(agentId) {
5115
+ const slug = agentId.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, 64);
5116
+ return slug || "agent";
5117
+ }
5118
+ var PREFLIGHT_TIMEOUT_MS = 20000;
5119
+ var AUTH_FAILURE = /authentication failed|could not read username|could not read password|permission denied \(publickey|invalid username or password|terminal prompts disabled|support for password authentication was removed|http basic: access denied|403 forbidden|returned error: 40[13]/i;
5120
+ function isGitAuthFailure(output) {
5121
+ return AUTH_FAILURE.test(output);
5122
+ }
5123
+ function remoteHost(remote) {
5124
+ try {
5125
+ const url = new URL(remote);
5126
+ if (url.protocol === "file:")
5127
+ return null;
5128
+ return url.hostname.toLowerCase() || null;
5129
+ } catch {
5130
+ const scp = remote.match(/^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)/);
5131
+ return scp ? scp[1].toLowerCase() : null;
5132
+ }
5133
+ }
5134
+ function remoteProvider(remote) {
5135
+ const host = remoteHost(remote) ?? "git";
5136
+ if (host === "github.com")
5137
+ return { provider: "github", providerName: "GitHub" };
5138
+ return { provider: host, providerName: host };
5139
+ }
5140
+ var runCommand2 = async (argv, options) => {
5141
+ try {
5142
+ const proc = Bun.spawn(argv, {
5143
+ cwd: options.cwd,
5144
+ env: {
5145
+ ...options.env,
5146
+ GIT_TERMINAL_PROMPT: "0",
5147
+ GH_PROMPT_DISABLED: "1"
5148
+ },
5149
+ stdin: "ignore",
5150
+ stdout: "pipe",
5151
+ stderr: "pipe"
5152
+ });
5153
+ const timer = setTimeout(() => proc.kill(), options.timeoutMs);
5154
+ const [stdout, stderr, code] = await Promise.all([
5155
+ new Response(proc.stdout).text(),
5156
+ new Response(proc.stderr).text(),
5157
+ proc.exited
5158
+ ]).finally(() => clearTimeout(timer));
5159
+ return { code, stdout, stderr };
5160
+ } catch (error) {
5161
+ return {
5162
+ code: null,
5163
+ stdout: "",
5164
+ stderr: error instanceof Error ? error.message : String(error)
5165
+ };
5166
+ }
5167
+ };
5168
+ async function checkRepositoryAuth(input, run = runCommand2) {
5169
+ const { remote, cwd, env, runnerName } = input;
5170
+ if (!remote || remoteHost(remote) === null)
5171
+ return null;
5172
+ const provider = remoteProvider(remote);
5173
+ const listed = await run(["git", "ls-remote", "--heads", remote], {
5174
+ cwd,
5175
+ env,
5176
+ timeoutMs: PREFLIGHT_TIMEOUT_MS
5177
+ });
5178
+ if (listed.code !== 0 && isGitAuthFailure(`${listed.stderr}
5179
+ ${listed.stdout}`)) {
5180
+ return {
5181
+ ...provider,
5182
+ body: `Runner ${runnerName} can't sign in to ${provider.providerName} to reach \`${remote}\`, so it could fetch and push nothing. Its owner can fix it on that machine \u2014 the link says how. Reply here once it's done and I'll carry on.`
5183
+ };
5184
+ }
5185
+ if (provider.provider !== "github" || !input.pullRequests)
5186
+ return null;
5187
+ const gh = await run(["gh", "auth", "status", "--hostname", "github.com"], {
5188
+ cwd,
5189
+ env,
5190
+ timeoutMs: PREFLIGHT_TIMEOUT_MS
5191
+ });
5192
+ if (gh.code === null || gh.code === 127)
5193
+ return null;
5194
+ if (gh.code !== 0) {
5195
+ return {
5196
+ ...provider,
5197
+ body: `The GitHub CLI on runner ${runnerName} is not signed in, so I couldn't open a pull request. Its owner can fix it on that machine \u2014 the link says how. Reply here once it's done and I'll carry on.`
5198
+ };
5199
+ }
5200
+ return null;
5201
+ }
5202
+
5082
5203
  // src/acp/templates/manager.md
5083
5204
  var manager_default = "You are the planning agent for this Linear workspace, acting as the app user `@hypotenuse`, on\n{{issue.identifier}} \u2014 {{issue.title}}. You are an AI agent; say so if anyone asks.\n\n## What you produce\n\n- **A PRD.** When the issue concerns a whole project, write it into the project's content with\n `linear_update_project` (read it first with `linear_get_project`). When a person already owns that content, put\n the PRD in a document attached to the project with `linear_create_document`, and revise it later with\n `linear_update_document`. Sections: Problem, Goals, Non-goals, User stories, Requirements, Open questions,\n Rollout. Read the code in your working directory so the PRD fits the codebase as it is.\n- **A breakdown into issues**, each small enough for one agent session: sub-issues of this issue\n (`linear_create_sub_issue`) or new issues (`linear_create_issue`), edited with `linear_update_issue`. Every issue\n has a title and a description with an **Acceptance criteria** checklist (`- [ ] \u2026`); labels chosen from\n `linear_list_labels`, never invented; an estimate where the team uses them; and `blocks` or `related` relations\n (`linear_create_relation`) where order matters. Look for existing issues first with `linear_search_issues` and\n `linear_get_issue`, so you do not file duplicates.\n\n## How you ask\n\nAsk clarifying questions with `linear_ask_user`, with `options` whenever the answer is a choice. Ask everything you\nneed at once, not one question per turn, then end your turn. Read what people already said with\n`linear_list_comments`.\n\n## What you never do\n\n- Write or change code, or run commands: your working directory is read-only. If answering a question needs\n something run, file a small spike issue and dispatch it.\n- Open or merge pull requests: `linear_report_pr` is for workers, and a worker's pull requests reach you in its\n review.\n- Move an issue to a completed or canceled state with `linear_set_issue_state` unless the tool tells you that is\n permitted. Moving an issue to an \"In Review\"-type started state is fine.\n- Dispatch an issue you did not create, or that a person has not delegated to you or put under this issue.\n- Have more dispatched issues in progress at once than the tool allows; it will tell you when you are at the\n limit.\n\n## How you dispatch\n\nDispatch only issues whose acceptance criteria are written: one `hypotenuse_dispatch` per issue, with\n`instructions` for context the worker needs that does not belong in the issue. A worker session starts on the\nissue on whichever runner can take it. Keep this session's plan current with `linear_set_plan` if your own plan\nupdates do not already appear.\n\n## How you review\n\nWhen a dispatched worker finishes, you get a review turn with its result: its outcome, final response, plan, and\npull requests. Check the result against the issue's acceptance criteria (`linear_get_issue`), then do one of:\n\n- **Accept**: say so in a `linear_comment` on the worker's issue (\"Reviewed against the acceptance criteria: \u2026\"),\n optionally moving it to an \"In Review\"-type state with `linear_set_issue_state`. A person merges the pull\n request.\n- **Request changes**: one `hypotenuse_dispatch` with `mode: \"changes\"` and specific `instructions` \u2014 what is\n missing or wrong, against which criterion. The same worker session continues on the same branch.\n- **File follow-ups**: new issues with `linear_create_issue`, related to the original with\n `linear_create_relation` (`related`). Dispatch them now only if they are ready.\n\nWhen requesting changes is refused because the review round limit is reached, stop sending the work back: end\nwith `linear_ask_user`, addressed to the person who started this session, explaining what is still wrong.\n\nEnd every review turn with a response that says which you chose, and why.\n\n## Writing in Linear\n\nTo mention a person or an issue, use its Linear URL, not an @name. Finish every turn with a short summary of what\nyou decided and what happens next.\n";
5084
5205
 
5085
5206
  // src/acp/templates/worker.md
5086
- var worker_default = `Implement {{issue.identifier}} \u2014 {{issue.title}} \u2014 in the working directory.
5087
-
5088
- - Read the issue and the Linear context below before you change anything, and keep a plan current as you go.
5089
- \`linear_get_issue\` and \`linear_list_comments\` read the issue and its thread when you need more.
5090
- - Commit in small steps; reference {{issue.identifier}} in every commit message.
5091
- - When the work is ready, push the branch and open a pull request that links {{issue.url}}, then call
5092
- \`linear_report_pr\` with its URL straight away.
5093
- - If a decision belongs to a person (scope, product behaviour, anything destructive), ask once, clearly, with
5094
- \`linear_ask_user\` \u2014 with options when there are a few clear choices \u2014 and end your turn there. Their answer
5095
- arrives as your next prompt.
5096
- - Your thoughts, tool calls, and final message already appear in Linear. Do not repeat them with \`linear_comment\`;
5097
- that is for a question someone left elsewhere on the issue, or a blocker for someone not following this session.
5098
- - To mention a person or an issue in Linear, use its Linear URL, not an @name.
5099
- - Finish with a short summary: what changed, what you tested, and what a reviewer should look at first. Do not list
5100
- every file.
5101
- `;
5207
+ var worker_default = "Implement {{issue.identifier}} \u2014 {{issue.title}} \u2014 in the working directory. You are an AI agent working as the\nLinear app user `@hypotenuse`; say so if anyone asks.\n\n- Read the issue and the Linear context below before you change anything, and keep a plan current as you go.\n `linear_get_issue` and `linear_list_comments` read the issue and its thread when you need more.\n- Commit in small steps; reference {{issue.identifier}} in every commit message.\n- When the work is ready, push the branch and open a pull request that links {{issue.url}}, then call\n `linear_report_pr` with its URL straight away.\n- If a decision belongs to a person (scope, product behaviour, anything destructive), ask once, clearly, with\n `linear_ask_user` \u2014 with options when there are a few clear choices \u2014 and end your turn there. Their answer\n arrives as your next prompt.\n- Your thoughts, tool calls, and final message already appear in Linear. Do not repeat them with `linear_comment`;\n that is for a question someone left elsewhere on the issue, or a blocker for someone not following this session.\n- To mention a person or an issue in Linear, use its Linear URL, not an @name.\n- Finish with a short summary: what changed, what you tested, and what a reviewer should look at first. Do not list\n every file.\n";
5102
5208
 
5103
5209
  // src/acp/prompt.ts
5104
5210
  var DEFAULT_TEMPLATES = {
@@ -5298,6 +5404,21 @@ class TurnFailure extends Error {
5298
5404
  }
5299
5405
  }
5300
5406
 
5407
+ class AuthRequired extends Error {
5408
+ need;
5409
+ unavailable;
5410
+ constructor(need, unavailable) {
5411
+ super(need.body);
5412
+ this.need = need;
5413
+ this.unavailable = unavailable;
5414
+ }
5415
+ }
5416
+ var SIGNED_IN_BODY = "Signed in \u2014 continuing.";
5417
+ function resumesAfterAuth(assignment) {
5418
+ const last = (assignment.history ?? []).filter((h) => h.direction === "outbound").at(-1);
5419
+ return last?.type === "elicitation" && last.signal === "auth";
5420
+ }
5421
+
5301
5422
  class AcpSessionExecutor {
5302
5423
  options;
5303
5424
  constructor(options = {}) {
@@ -5337,6 +5458,7 @@ class Turn {
5337
5458
  conn = null;
5338
5459
  acpSessionId = null;
5339
5460
  agentTitle;
5461
+ agentVersion = null;
5340
5462
  endReason = null;
5341
5463
  ended;
5342
5464
  endNow;
@@ -5446,9 +5568,30 @@ class Turn {
5446
5568
  });
5447
5569
  await this.prepare();
5448
5570
  await this.start();
5571
+ if (resumesAfterAuth(ctx.assignment))
5572
+ this.emit({ type: "thought", body: SIGNED_IN_BODY, ephemeral: false });
5449
5573
  timer = setInterval(() => this.tick(), this.options.tickMs ?? TICK_MS);
5450
5574
  return await this.prompt();
5451
5575
  } catch (error) {
5576
+ if (error instanceof AuthRequired && !this.endReason) {
5577
+ if (error.unavailable)
5578
+ ctx.agentAvailability(false, error.unavailable);
5579
+ this.log.note(`needs a login: ${error.need.provider}`);
5580
+ this.emitAll(this.mapper.flushThought(false));
5581
+ this.emit({
5582
+ type: "elicitation",
5583
+ body: error.need.body,
5584
+ signal: "auth",
5585
+ signalMetadata: {
5586
+ provider: error.need.provider,
5587
+ providerName: error.need.providerName.slice(0, 255)
5588
+ }
5589
+ });
5590
+ this.mapper.askedUser = true;
5591
+ return this.result("completed");
5592
+ }
5593
+ if (error instanceof AuthRequired)
5594
+ return await this.stopped();
5452
5595
  if (error instanceof TurnFailure) {
5453
5596
  if (error.unavailable)
5454
5597
  ctx.agentAvailability(false, error.unavailable);
@@ -5482,6 +5625,14 @@ class Turn {
5482
5625
  env: (branch) => sessionEnvironment(ctx.env, sessionVariables(assignment, branch))
5483
5626
  });
5484
5627
  } catch (error) {
5628
+ const remote = ctx.assignment.repository?.remote ?? null;
5629
+ if (error instanceof WorkspaceError && remote && isGitAuthFailure(error.body)) {
5630
+ const provider = remoteProvider(remote);
5631
+ throw new AuthRequired({
5632
+ ...provider,
5633
+ body: `Runner ${ctx.runnerName} can't sign in to ${provider.providerName} to fetch \`${remote}\`. Its owner can fix it on that machine \u2014 the link says how. Reply here once it's done and I'll carry on.`
5634
+ });
5635
+ }
5485
5636
  if (error instanceof WorkspaceError)
5486
5637
  throw new TurnFailure(error.body);
5487
5638
  throw error;
@@ -5491,6 +5642,17 @@ class Turn {
5491
5642
  } else {
5492
5643
  mkdirSync6(this.cwd, { recursive: true, mode: 448 });
5493
5644
  }
5645
+ if (this.endReason)
5646
+ throw new TurnFailure("stopped");
5647
+ const need = await checkRepositoryAuth({
5648
+ remote: this.workspace?.repository?.remote ?? null,
5649
+ cwd: this.cwd,
5650
+ env: ctx.env,
5651
+ runnerName: ctx.runnerName,
5652
+ pullRequests: (assignment.role ?? "worker") === "worker"
5653
+ }, this.options.run);
5654
+ if (need)
5655
+ throw new AuthRequired(need);
5494
5656
  if (this.endReason)
5495
5657
  throw new TurnFailure("stopped");
5496
5658
  this.mapperOptions.cwd = this.cwd;
@@ -5566,7 +5728,7 @@ class Turn {
5566
5728
  throw this.fsError(error);
5567
5729
  }
5568
5730
  },
5569
- createElicitation: (params) => this.questions.ask(elicitationQuestion(params, this.agentTitle)),
5731
+ createElicitation: (params) => this.questions.ask(elicitationQuestion(params, this.agentTitle, providerSlug(this.agentId))),
5570
5732
  unknown: (method) => this.log.note(`unhandled ${method}`)
5571
5733
  }
5572
5734
  });
@@ -5579,6 +5741,7 @@ class Turn {
5579
5741
  ctx.setState("starting");
5580
5742
  const init = await this.guard(conn.initialize(package_default.version), launch.startupTimeoutMs, `${this.agentId} did not answer initialize within ${launch.startupTimeoutMs / 1000}s (\`${launch.command.join(" ")}\`).`, true);
5581
5743
  this.agentTitle = init.agentInfo?.title ?? init.agentInfo?.name ?? this.agentId;
5744
+ this.agentVersion = init.agentInfo?.version ?? null;
5582
5745
  ctx.log.debug(`${this.agentId} initialized: ${init.agentInfo?.name ?? "?"} ${init.agentInfo?.version ?? ""}`, { session: assignment.agentSessionId });
5583
5746
  await this.openSession(init, launch.authMethod);
5584
5747
  ctx.agentAvailability(true);
@@ -5620,21 +5783,30 @@ class Turn {
5620
5783
  await this.chooseSession(init.agentCapabilities ?? {});
5621
5784
  return;
5622
5785
  } catch (error) {
5623
- if (!isAuthRequired(error) || authenticated) {
5786
+ if (isAuthRequired(error) && authenticated) {
5787
+ throw new AuthRequired(this.loginNeed(), "not logged in");
5788
+ }
5789
+ if (!isAuthRequired(error)) {
5624
5790
  if (error instanceof TurnFailure)
5625
5791
  throw error;
5626
5792
  throw new TurnFailure(`${this.agentId} could not open a session: ${errorMessage(error)}${quoteTail(this.log.tail())}`);
5627
5793
  }
5628
5794
  const method = this.authMethodFor(init.authMethods ?? [], configuredMethod);
5629
- if (!method) {
5630
- throw new TurnFailure(`${this.agentId} on runner ${this.ctx.runnerName} is not logged in. On that machine, run: \`hypotenuse agents login ${this.agentId}\``, "not logged in");
5631
- }
5795
+ if (!method)
5796
+ throw new AuthRequired(this.loginNeed(), "not logged in");
5632
5797
  this.log.note(`authenticate ${method}`);
5633
5798
  await this.guard(this.conn.authenticate(method), 0, "");
5634
5799
  authenticated = true;
5635
5800
  }
5636
5801
  }
5637
5802
  }
5803
+ loginNeed() {
5804
+ return {
5805
+ provider: providerSlug(this.agentId),
5806
+ providerName: this.agentTitle,
5807
+ body: `${this.agentTitle} on runner ${this.ctx.runnerName} is not logged in. Its owner can sign in on that machine with \`hypotenuse agents login ${this.agentId}\` \u2014 the link has the steps. Reply here once it's done and I'll carry on.`
5808
+ };
5809
+ }
5638
5810
  authMethodFor(methods, configured) {
5639
5811
  const agentRun = methods.filter((m) => m.type !== "terminal");
5640
5812
  if (configured)
@@ -5772,7 +5944,16 @@ class Turn {
5772
5944
  const stop = answer.stopReason === "cancelled" ? "end_turn" : answer.stopReason;
5773
5945
  if (this.mapper.askedUser && this.mapper.hasMessage)
5774
5946
  this.log.note(`said after asking (kept out of Linear): ${this.mapper.message().slice(0, 2000)}`);
5775
- this.emitAll(this.mapper.finish(stop, { branch: this.branch }));
5947
+ this.emitAll(this.mapper.finish(stop, {
5948
+ branch: this.branch,
5949
+ signature: responseSignature({
5950
+ role: this.ctx.assignment.role ?? "worker",
5951
+ agentId: this.agentId,
5952
+ agentTitle: this.agentTitle,
5953
+ agentVersion: this.agentVersion,
5954
+ runnerName: this.ctx.runnerName
5955
+ })
5956
+ }));
5776
5957
  return stop === "end_turn" ? this.result("completed") : this.result("failed", `stopReason: ${stop}`);
5777
5958
  }
5778
5959
  repositoryName() {
@@ -8094,8 +8275,9 @@ class Supervisor {
8094
8275
  this.silence(id);
8095
8276
  for (const cancel of answer.cancels) {
8096
8277
  const slot = this.slots.get(cancel.workItemId);
8097
- if (slot && !slot.reason && cancel.by)
8098
- slot.killNote = cancel.by;
8278
+ const note = cancel.by ?? cancel.said;
8279
+ if (slot && !slot.reason && note)
8280
+ slot.killNote = note;
8099
8281
  this.stop(cancel.workItemId, cancel.reason);
8100
8282
  }
8101
8283
  for (const reply of answer.answers ?? [])
@@ -10201,6 +10383,98 @@ function registerDoctorCommand(program) {
10201
10383
  });
10202
10384
  }
10203
10385
 
10386
+ // src/commands/event.ts
10387
+ function routedAs(event) {
10388
+ const s = event.session;
10389
+ if (!s?.role)
10390
+ return "-";
10391
+ const why = s.routingRule ? `rule ${s.routingRule.name}` : s.dispatched ? "dispatched" : "default";
10392
+ return `${s.role} (${why})`;
10393
+ }
10394
+ function turnPlace(event) {
10395
+ const t = event.turn;
10396
+ if (!t)
10397
+ return "-";
10398
+ if (t.status === "queued") {
10399
+ return `queued: ${t.waitingCode ?? t.waitingReason ?? "waiting"}`;
10400
+ }
10401
+ if (t.status === "cancelled") {
10402
+ return `cancelled${t.cancelReason ? `: ${t.cancelReason}` : ""}`;
10403
+ }
10404
+ return t.runner ? `${t.status} on ${t.runner.name}` : t.status;
10405
+ }
10406
+ function describeEvent(event, payload) {
10407
+ const lines = [
10408
+ `${event.eventType} ${event.action}${event.issueIdentifier ? ` ${event.issueIdentifier}` : ""}`,
10409
+ `outcome ${event.outcome}`,
10410
+ `summary ${event.summary ?? "-"}`,
10411
+ `received ${when(event.receivedAt)}`,
10412
+ `processed ${when(event.processedAt)}`,
10413
+ `delivery ${event.deliveryId}`
10414
+ ];
10415
+ if (event.error)
10416
+ lines.push(`error ${event.error}`);
10417
+ if (event.session) {
10418
+ lines.push(`session ${event.session.id} (${event.session.status})`);
10419
+ lines.push(`routed as ${routedAs(event)}`);
10420
+ }
10421
+ if (event.turn) {
10422
+ lines.push(`turn ${event.turn.id} ${event.turn.kind}, ${turnPlace(event)}`);
10423
+ if (event.turn.waitingReason && event.turn.status === "queued") {
10424
+ lines.push(`waiting ${event.turn.waitingReason}`);
10425
+ }
10426
+ }
10427
+ lines.push("", payload ? JSON.stringify(payload, null, 2) : "payload (project admins only)");
10428
+ return lines.join(`
10429
+ `);
10430
+ }
10431
+ function registerEventCommands(program) {
10432
+ const event = program.command("event").description("The selected project's event log: Linear's webhooks and how each was routed");
10433
+ withPaging(event.command("list").description("List webhook events, newest first").option("--type <eventType>", "only this event type, e.g. AgentSessionEvent, Issue, PermissionChange").option("--outcome <outcome>", "only this outcome: handled, ignored, duplicate_of_dispatch, noted, mention_without_session, assigned_not_delegated, disengaged, failed, pending").option("--hide-ignored", "leave out events Hypotenuse did not act on").option("--session <id>", "only events about this agent session").option("--since <time>", "only events since: ISO-8601, epoch ms, or 7d / 12h / 30m")).action(async function() {
10434
+ await runCommand(this, async (ctx) => {
10435
+ const opts = this.opts();
10436
+ const projectId = await ctx.projectId();
10437
+ const result = await ctx.signedInClient().request("GET", "/trigger-events", {
10438
+ query: {
10439
+ projectId,
10440
+ eventType: opts.type,
10441
+ outcome: opts.outcome,
10442
+ hideIgnored: opts.hideIgnored ? true : undefined,
10443
+ agentSessionId: opts.session,
10444
+ since: opts.since ? parseTime(opts.since) : undefined,
10445
+ ...pageQuery(this)
10446
+ }
10447
+ });
10448
+ emit(ctx, result, () => table(result.triggerEvents.map((e) => ({
10449
+ id: e.id,
10450
+ received: when(e.receivedAt),
10451
+ event: `${e.eventType} ${e.action}`,
10452
+ issue: e.issueIdentifier ?? "-",
10453
+ outcome: e.outcome,
10454
+ "routed as": routedAs(e),
10455
+ turn: turnPlace(e),
10456
+ summary: e.summary ?? ""
10457
+ })), [
10458
+ { key: "id" },
10459
+ { key: "received" },
10460
+ { key: "event" },
10461
+ { key: "issue" },
10462
+ { key: "outcome" },
10463
+ { key: "routed as" },
10464
+ { key: "turn", maxWidth: 40 },
10465
+ { key: "summary", maxWidth: 70 }
10466
+ ]) + pageFooter(result.pagination));
10467
+ });
10468
+ });
10469
+ event.command("view <id>").description("Show one event: what it did, how it was routed, and (admins) its payload").action(async function(id) {
10470
+ await runCommand(this, async (ctx) => {
10471
+ const projectId = await ctx.projectId();
10472
+ const result = await ctx.signedInClient().request("GET", `/trigger-event/${id}`, { query: { projectId } });
10473
+ emit(ctx, result, () => describeEvent(result.triggerEvent, result.payload));
10474
+ });
10475
+ });
10476
+ }
10477
+
10204
10478
  // src/commands/identity.ts
10205
10479
  function linkedViaText(via) {
10206
10480
  return via === "email" ? "e-mail match" : via === "oauth" ? "Linear sign-in" : "admin";
@@ -11995,6 +12269,7 @@ function buildProgram() {
11995
12269
  registerAuditCommands(program);
11996
12270
  registerIntegrationCommands(program);
11997
12271
  registerSessionCommands(program);
12272
+ registerEventCommands(program);
11998
12273
  registerRunnerCommands(program);
11999
12274
  registerRoutingCommands(program);
12000
12275
  registerIdentityCommands(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadeai/hypotenuse",
3
- "version": "0.11.1",
3
+ "version": "0.13.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",
@@ -35,6 +35,7 @@ server. It calls the same HTTP actions the dashboard does, as the person who sig
35
35
  | `hypotenuse audit list [--since 7d] [--until <time>] [--action tag:edit]` | Who changed what, newest first (admin). Times are ISO-8601, epoch ms, or `7d` / `12h` / `30m`. |
36
36
  | `hypotenuse integration list\|view\|connect\|disconnect` | The selected project's trigger integrations — today the only type is `linear`, its Linear workspace connection. `list` shows each type this server supports and whether the project is connected. `view <type>` shows the workspace, the `@hypotenuse` app user, scopes, and token health (never a token). `connect <type>` (admin) prints the consent URL — a **workspace admin** of that system (for `linear`, a Linear workspace admin) must approve it in a browser — and waits until the integration is active (`--no-browser`, `--timeout <seconds>`). `disconnect <type> --yes` (admin) revokes the tokens; the agent stops answering there at once. |
37
37
  | `hypotenuse session list\|view\|cancel` | The selected project's agent sessions — each time `@hypotenuse` was delegated an issue or mentioned, in any trigger type (Linear today). `list [--status <status>] [--role manager\|worker] [--type linear] [--issue ENG-123] [--runner <id>]`, newest first; `view <id\|ENG-123>` (an issue identifier names its newest session) shows the session (issue, status, origin, role, runner, issuer, links), its turns (kind, status, runner, and for a waiting turn its reason code such as `offline` or `no_capacity`), and every activity in order, both directions, with each write's delivery status (`pending`, `sent`, `failed`, `superseded`); `--transcript` prints only the activities; `--follow [--interval <seconds>]` keeps printing new ones until interrupted (one JSON object per line under `--json`); `cancel <id>` stops its work as Stop in Linear does (work not started is cancelled; a running turn is told to stop and report). |
38
+ | `hypotenuse event list\|view` | The selected project's event log: every webhook its Linear workspace sent in the last 14 days and how each was routed. `list [--type AgentSessionEvent\|Issue\|…] [--outcome handled\|ignored\|duplicate_of_dispatch\|failed\|pending] [--hide-ignored] [--session <id>] [--since 12h]`, newest first, shows each event's issue, outcome, what it did, the role it was routed as and why (`worker (default)`, `manager (rule /manage)`, `worker (dispatched)`), and where its turn is now (`queued: no_capacity`, `running on <runner>`); `view <id>` shows one in full, with the payload Linear sent for project admins only. |
38
39
  | `hypotenuse runner register\|list\|view\|edit\|revoke` | The selected project's runners — machines that run coding agents. `register [--name <name>] [--replace] [--force]` registers **this** machine as the signed-in person, saves the runner token to `credentials.json` (never printed), and sends a first heartbeat; `--replace` revokes the runner of that name first. `list [--status online\|offline\|draining\|revoked] [--all]`; `view <id\|name>` shows capabilities, subscriptions, and whether its socket is attached; `edit <id\|name> [--name] [--enable\|--disable]` renames or pauses it; `revoke <id\|name> --yes` revokes it for good (its owner or an admin). |
39
40
  | `hypotenuse routing list\|add\|edit\|remove\|reorder\|preview\|settings` | The selected project's routing rules — the first enabled rule whose match holds decides whether a new session gets a **manager** or a **worker** (no match: worker). `list`; `add --name <name> --role manager\|worker [--label <l>] [--team <key>] [--linear-project <id>] [--prefix /manage] [--has-parent\|--no-parent] [--origin delegation\|mention] [--repository <repo>] [--disabled]` (labels and team keys are checked against the connected workspace); `edit <id>` with the same flags (any match flag replaces the whole match) plus `--enable\|--disable` and `--clear-repository`; `remove <id>`; `reorder <id...>` lists every rule id in the new order; `preview <ENG-123> [--prompt "/manage ..."] [--with-repository]` shows how the issue would route if delegated now — the matching rule, the role, every runner with whether it could take the work and its reason codes, and the repository (any member; `--with-repository` asks Linear to rank repositories, spending its API budget); `settings` shows the routing timers, `settings [--queue-timeout 30m] [--sticky-timeout 10m] [--offer-ttl 10s] [--stop-grace 30s] [--repository-confidence 0.7] [--max-review-rounds 3] [--reset <key>]` changes them. Changes are admin-only. |
40
41
  | `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. |