superagent-claude 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ It needs Node.js 22.19 or later. `npx superagent-claude start --config superagen
20
20
 
21
21
  `auth.mode` picks which one (`api-key` or `subscription`); Bedrock and Vertex also work. See [docs/authentication.md](docs/authentication.md) for both modes and the subscription terms warning.
22
22
 
23
- The config needs `repositories`, `policy.allowed` and `auth.mode`. See [docs/getting-started.md](docs/getting-started.md), which also covers building from source.
23
+ The config needs `repositories` and `policy.allowed`; `auth.mode` defaults to `api-key` and needs its credential. See [docs/getting-started.md](docs/getting-started.md), which also covers building from source.
24
24
 
25
25
  A container image is published as `ghcr.io/ok0/superagent-claude`. See [Docker](docs/deploy.md#docker) to run it.
26
26
 
package/lib/cli.js CHANGED
@@ -55,7 +55,7 @@ const exitSoon = () => {
55
55
  process.exit();
56
56
  }, EXIT_GRACE_MS).unref();
57
57
  };
58
- import("./main-DKxToYW7.js").then(({ main }) => main(argv, {
58
+ import("./main-CingWi3i.js").then(({ main }) => main(argv, {
59
59
  out: (s) => {
60
60
  process.stdout.write(s);
61
61
  },
@@ -13,7 +13,7 @@ import { DatabaseSync } from "node:sqlite";
13
13
  import { checkSync, lockSync } from "proper-lockfile";
14
14
  import { fileURLToPath } from "node:url";
15
15
  // package.json
16
- var version = "0.2.0";
16
+ var version = "0.3.1";
17
17
  // src/infra/env.ts
18
18
  /**
19
19
  * Environment for subprocesses: only the inherited whitelist (PATH, HOME, LANG) plus explicitly named variables.
@@ -1438,6 +1438,7 @@ var Assignments = class extends EnginePart {
1438
1438
  if (report.state === "blocked" || report.state === "failed") ticket.undelivered = ticket.undelivered.filter((id) => !automatic(ticket.inputs.find((input) => input.id === id)));
1439
1439
  this.repo.tickets.save(ticket);
1440
1440
  if (report.limit && report.state === "blocked") this.replies.limitStopped(ticket, assignment.id, report.limit, pull);
1441
+ if (report.workspace && report.state === "blocked") this.replies.workspaceBlocked(ticket, assignment.id, summary);
1441
1442
  const ready = !ticket.follows?.length ? "completed" : this.tickets.followsDone(ticket)?.outcome;
1442
1443
  const outcome = report.state === "dropped" ? this.tickets.followsDone(ticket)?.outcome ?? "dropped" : report.state === "ready" ? ready : void 0;
1443
1444
  if (outcome) this.tickets.end(ticketId, "executor", outcome, at);
@@ -2080,6 +2081,10 @@ function refusalText(reason) {
2080
2081
  function limitText(limit, pull) {
2081
2082
  return `I stopped before finishing: the ${limit} limit for this task was reached.` + (pull ? ` Work so far: ${pull}.` : "") + " Reply here to continue.";
2082
2083
  }
2084
+ /** The note a person gets when a configured branch is missing, so the task could not start. */
2085
+ function workspaceBlockedText(reason) {
2086
+ return `I could not start this task. ${reason} Reply here once that is fixed to try again.`;
2087
+ }
2083
2088
  /**
2084
2089
  * Each message is queued under a deterministic id, so it is never sent twice. Replies to a person go where the person's
2085
2090
  * message was written, and a result where the latest person's message to the ticket was written.
@@ -2118,6 +2123,24 @@ var Replies = class extends EnginePart {
2118
2123
  ticketId: ticket.id
2119
2124
  });
2120
2125
  }
2126
+ /**
2127
+ * Queue the reason the task could not start where the latest person's message to the ticket was written, else where
2128
+ * it started; nothing when there is no conversation. Its id is `workspace-blocked:<assignmentId>:<triggerId>` for
2129
+ * the latest person's message, so a repeat or restart sends nothing more, and a person's reply that meets the same
2130
+ * block is told again.
2131
+ */
2132
+ workspaceBlocked(ticket, assignmentId, reason) {
2133
+ const channel = this.replyChannel(ticket);
2134
+ if (!channel) return;
2135
+ const latest = ticket.inputs.findLast((input) => input.kind === "trigger" && input.human === true && !input.context)?.triggerId ?? (this.repo.triggers.get(ticket.origin.triggerId)?.human ? ticket.origin.triggerId : void 0);
2136
+ this.options.outbox.send({
2137
+ id: latest ? `workspace-blocked:${assignmentId}:${latest}` : `workspace-blocked:${assignmentId}`,
2138
+ channel,
2139
+ text: workspaceBlockedText(reason),
2140
+ kind: "reply",
2141
+ ticketId: ticket.id
2142
+ });
2143
+ }
2121
2144
  withdrawAttention(attentionId) {
2122
2145
  this.options.outbox.withdraw(key("attention-reply", attentionId));
2123
2146
  }
@@ -2866,6 +2889,12 @@ function settingsPolicy(settings, sources) {
2866
2889
  }
2867
2890
  const DEFAULT_STATE_DIR = "~/.superagent-claude";
2868
2891
  const RepositorySchema = z.string().trim().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, "must be owner/name");
2892
+ const BranchNameSchema = z.string().regex(/^(?![-/])(?!.*\.\.)(?!.*\/\/)(?!.*\/$)(?!.*\.lock$)[A-Za-z0-9._/-]{1,200}$/, "must be a branch name: letters, digits and . _ - /, not starting with - or /, without .. or //, not ending with / or .lock");
2893
+ const RepositoryEntrySchema = z.union([RepositorySchema, z.object({
2894
+ repository: RepositorySchema,
2895
+ branchFrom: BranchNameSchema.optional(),
2896
+ pullRequestTo: BranchNameSchema.optional()
2897
+ }).strict()]);
2869
2898
  const AUTH_MODES = Object.keys(AUTH_ENV);
2870
2899
  /** A refused start (exit code 2); `rule` names the violated startup rule. */
2871
2900
  var StartupRefused = class extends Error {
@@ -2877,7 +2906,23 @@ var StartupRefused = class extends Error {
2877
2906
  }
2878
2907
  };
2879
2908
  const ConfigSchema = z.object({
2880
- repositories: z.array(RepositorySchema).min(1, "must name at least one repository"),
2909
+ repositories: z.array(RepositoryEntrySchema).min(1, "must name at least one repository").superRefine((items, ctx) => {
2910
+ const seen = /* @__PURE__ */ new Set();
2911
+ const reported = /* @__PURE__ */ new Set();
2912
+ for (const item of items) {
2913
+ const name = typeof item === "string" ? item : item.repository;
2914
+ const key = name.toLowerCase();
2915
+ if (seen.has(key)) {
2916
+ if (!reported.has(key)) {
2917
+ reported.add(key);
2918
+ ctx.addIssue({
2919
+ code: "custom",
2920
+ message: `lists ${name} more than once`
2921
+ });
2922
+ }
2923
+ } else seen.add(key);
2924
+ }
2925
+ }).transform((items) => items.map((item) => typeof item === "string" ? item : item.repository)),
2881
2926
  policy: z.object({
2882
2927
  allowed: z.array(LoginSchema).min(1, "must name at least one login"),
2883
2928
  acceptMode: z.enum([
@@ -2932,6 +2977,24 @@ const ConfigSchema = z.object({
2932
2977
  }),
2933
2978
  stateDir: z.string().trim().min(1).default(DEFAULT_STATE_DIR)
2934
2979
  }).strict();
2980
+ function repositoryBranches(entries) {
2981
+ const parsed = z.array(RepositoryEntrySchema).safeParse(entries);
2982
+ if (!parsed.success) return {};
2983
+ const branches = {};
2984
+ for (const entry of parsed.data) {
2985
+ if (typeof entry === "string") continue;
2986
+ const { repository, branchFrom, pullRequestTo } = entry;
2987
+ if (branchFrom === void 0 && pullRequestTo === void 0) continue;
2988
+ branches[repository.toLowerCase()] = {
2989
+ ...branchFrom !== void 0 ? { branchFrom } : {},
2990
+ ...pullRequestTo !== void 0 ? { pullRequestTo } : {}
2991
+ };
2992
+ }
2993
+ return branches;
2994
+ }
2995
+ function branchesOf(config, repository) {
2996
+ return config.branches?.[repository.toLowerCase()] ?? {};
2997
+ }
2935
2998
  var ConfigError = class extends StartupRefused {
2936
2999
  constructor(message) {
2937
3000
  super("config", message);
@@ -2960,9 +3023,11 @@ function loadConfig(path, env = process.env) {
2960
3023
  const result = ConfigSchema.safeParse(raw);
2961
3024
  if (!result.success) throw new ConfigError(`The config file ${path} is invalid:\n` + result.error.issues.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n"));
2962
3025
  const data = result.data;
3026
+ const branches = repositoryBranches(raw.repositories);
2963
3027
  return {
2964
3028
  ...data,
2965
- stateDir: expandStateDir(data.stateDir, dirname(path), env)
3029
+ stateDir: expandStateDir(data.stateDir, dirname(path), env),
3030
+ ...Object.keys(branches).length ? { branches } : {}
2966
3031
  };
2967
3032
  }
2968
3033
  const byLogin = (a, b) => {
@@ -3001,18 +3066,8 @@ const SUBSCRIPTION_POLICY_WARNING = "auth.mode subscription runs the agent on a
3001
3066
  const SHARED_USE_WARNING = "ToS risk: with auth.mode subscription, people other than the subscription owner (policy.allowed is not exactly [auth.owner]) can make the agent work on the owner's subscription; this shared use may violate the subscription terms.";
3002
3067
  const SANDBOX_DOC = "docs/deploy.md#sandbox-in-containers";
3003
3068
  const UNSANDBOXED_WARNING = `security.allowUnsandboxed is true and the runtime sandbox is unavailable here: sessions run without the sandbox, network.allow is not enforced and commands can read the agent's tokens from other processes; restrict egress outside the agent (see ${SANDBOX_DOC})`;
3004
- const RULE_NUMBER = {
3005
- credential: 1,
3006
- "allowed-empty": 2,
3007
- "repositories-empty": 3,
3008
- "agent-allowed": 4,
3009
- "subscription-shared": 6,
3010
- unsandboxed: 7,
3011
- "no-repository": 8
3012
- };
3013
3069
  function refused(rule, text) {
3014
- const number = RULE_NUMBER[rule];
3015
- return new StartupRefused(rule, number ? `Startup refused (rule ${number}, ${rule}): ${text}` : `Startup refused (${rule}): ${text}`);
3070
+ return new StartupRefused(rule, `Startup refused (${rule}): ${text}`);
3016
3071
  }
3017
3072
  const present = (value) => typeof value === "string" && value.trim() !== "";
3018
3073
  function resolveCredential(mode, env) {
@@ -3257,6 +3312,13 @@ var ReadLedger = class {
3257
3312
  };
3258
3313
  const DRAIN_MS = 2e3;
3259
3314
  const workerKey = (ticketId) => `worker-${ticketId}`;
3315
+ /** A configured branch is missing on the remote; the message is the reason a person reads. */
3316
+ var WorkspaceBlocked = class extends Error {
3317
+ constructor(message) {
3318
+ super(message);
3319
+ this.name = "WorkspaceBlocked";
3320
+ }
3321
+ };
3260
3322
  /**
3261
3323
  * Mode of a ticket Input (see docs/runtime-contract.md): a person's message steers; notes, reopen markers and
3262
3324
  * a message no person wrote that waited on a ready ticket (`quiet`) are injected; everything else is a followup.
@@ -3542,6 +3604,14 @@ var WorkerExecutor = class {
3542
3604
  try {
3543
3605
  workspace = await this.deps.prepare(ticket);
3544
3606
  } catch (e) {
3607
+ if (e instanceof WorkspaceBlocked) {
3608
+ this.report(ticketId, {
3609
+ state: "blocked",
3610
+ summary: e.message,
3611
+ workspace: true
3612
+ });
3613
+ return;
3614
+ }
3545
3615
  this.report(ticketId, {
3546
3616
  state: "failed",
3547
3617
  summary: `The workspace could not be prepared: ${this.message(e)}`
@@ -6389,6 +6459,7 @@ function truncateOutput(text, maxLines, maxBytes) {
6389
6459
  * Clone and fetch get the injected access env (authentication), and errors mask its secrets.
6390
6460
  * Publishing pushes an agent branch without force, adding co-author trailers only to commits not yet pushed.
6391
6461
  * Access errors carry the HTTP status when one is known. A transient push failure is retried up to 3 times (2 s, 5 s).
6462
+ * A worktree starts from the configured remote branch, the branch choice is fixed per ticket in a host file outside the worktree, and a missing configured branch stops prepare or push with `WorkspaceBlocked`.
6392
6463
  */
6393
6464
  var RepositoryAccessError = class extends Error {
6394
6465
  repository;
@@ -6491,7 +6562,30 @@ var Workspaces = class {
6491
6562
  clonePath(repository) {
6492
6563
  return join(this.stateDir, "repos", repository.replace("/", "__"));
6493
6564
  }
6494
- async prepare(ticket, name) {
6565
+ branchesPath(ticketId) {
6566
+ return join(this.stateDir, "branches", ticketId + ".json");
6567
+ }
6568
+ fixedBranches(ticketId) {
6569
+ const path = this.branchesPath(ticketId);
6570
+ if (!existsSync(path)) return void 0;
6571
+ const record = JSON.parse(readFileSync(path, "utf8"));
6572
+ if (typeof record !== "object" || record === null || Array.isArray(record)) throw new Error(`The branch record of ticket ${ticketId} is unreadable`);
6573
+ const { from, to } = record;
6574
+ if (from !== void 0 && typeof from !== "string" || to !== void 0 && typeof to !== "string") throw new Error(`The branch record of ticket ${ticketId} is unreadable`);
6575
+ return {
6576
+ ...from !== void 0 ? { from } : {},
6577
+ ...to !== void 0 ? { to } : {}
6578
+ };
6579
+ }
6580
+ remoteHas(clone, name) {
6581
+ return this.git([
6582
+ "rev-parse",
6583
+ "--verify",
6584
+ "--quiet",
6585
+ "refs/remotes/origin/" + name
6586
+ ], clone).then(() => true, () => false);
6587
+ }
6588
+ async prepare(ticket, name, branches = {}) {
6495
6589
  if (!NAME.test(name) || name.includes("..") || name.endsWith(".lock")) throw new Error("Invalid worktree name: " + name);
6496
6590
  const repository = this.repositoryOf(ticket);
6497
6591
  if (!REPOSITORY.test(repository)) throw new RepositoryAccessError(repository, "not an owner/name repository");
@@ -6517,6 +6611,15 @@ var Workspaces = class {
6517
6611
  const base = await this.clone(repository);
6518
6612
  mkdirSync(dirname(cwd), { recursive: true });
6519
6613
  await this.git(["worktree", "prune"], base);
6614
+ const stored = this.fixedBranches(ticket.id);
6615
+ const fixed = stored ?? {
6616
+ ...branches.branchFrom ? { from: branches.branchFrom } : {},
6617
+ ...branches.pullRequestTo ? { to: branches.pullRequestTo } : {}
6618
+ };
6619
+ const names = [...new Set([fixed.from, fixed.to].filter((name) => name !== void 0))];
6620
+ const missing = [];
6621
+ for (const name of names) if (!await this.remoteHas(base, name)) missing.push(name);
6622
+ if (missing.length) throw new WorkspaceBlocked(`${repository} has no branch ${missing.join(", ")}, so no workspace was made. A person must create the branch or change branchFrom or pullRequestTo for ${repository} in the config.`);
6520
6623
  const exists = await this.git([
6521
6624
  "rev-parse",
6522
6625
  "--verify",
@@ -6535,8 +6638,16 @@ var Workspaces = class {
6535
6638
  "-b",
6536
6639
  branch,
6537
6640
  cwd,
6538
- "origin/HEAD"
6641
+ fixed.from ? "origin/" + fixed.from : "origin/HEAD"
6539
6642
  ], base);
6643
+ if (stored === void 0) {
6644
+ const path = this.branchesPath(ticket.id);
6645
+ mkdirSync(dirname(path), {
6646
+ recursive: true,
6647
+ mode: 448
6648
+ });
6649
+ writeFileSync(path, JSON.stringify(fixed) + "\n", { mode: 384 });
6650
+ }
6540
6651
  return {
6541
6652
  repository,
6542
6653
  cwd,
@@ -6580,6 +6691,7 @@ var Workspaces = class {
6580
6691
  return dir;
6581
6692
  }
6582
6693
  async remove(ticketId) {
6694
+ rmSync(this.branchesPath(ticketId), { force: true });
6583
6695
  const path = this.worktreePath(ticketId);
6584
6696
  if (!existsSync(path)) return;
6585
6697
  const common = existsSync(join(path, ".git")) ? (await this.git([
@@ -6648,6 +6760,18 @@ var Workspaces = class {
6648
6760
  "HEAD"
6649
6761
  ], cwd)).trim();
6650
6762
  }
6763
+ async reviewBase(cwd, repo) {
6764
+ const configured = this.fixedBranches(basename(resolve(cwd)))?.from;
6765
+ if (configured === void 0) return "origin/HEAD";
6766
+ return await this.git([
6767
+ ...repo,
6768
+ ...GIT_READ_OPTIONS,
6769
+ "rev-parse",
6770
+ "--verify",
6771
+ "--quiet",
6772
+ "refs/remotes/origin/" + configured
6773
+ ], cwd).then(() => true, () => false) ? "refs/remotes/origin/" + configured : "origin/HEAD";
6774
+ }
6651
6775
  async diffStat(cwd, base) {
6652
6776
  const repo = this.repository(cwd);
6653
6777
  const known = base !== void 0 && SHA.test(base) && await this.git([
@@ -6661,7 +6785,7 @@ var Workspaces = class {
6661
6785
  ...repo,
6662
6786
  ...GIT_READ_OPTIONS,
6663
6787
  "merge-base",
6664
- "origin/HEAD",
6788
+ await this.reviewBase(cwd, repo),
6665
6789
  "HEAD"
6666
6790
  ], cwd)).trim();
6667
6791
  const out = await this.git([
@@ -6742,7 +6866,13 @@ var Workspaces = class {
6742
6866
  const branch = (await this.git(["branch", "--show-current"], cwd)).trim();
6743
6867
  if (!branch.startsWith("agent/")) throw new Error("The worktree is not on its agent branch");
6744
6868
  const clone = await this.clone(repository);
6745
- const base = (await this.git([
6869
+ const fixed = this.fixedBranches(ticket.id);
6870
+ const target = fixed?.to ?? fixed?.from;
6871
+ let base;
6872
+ if (target) {
6873
+ if (!await this.remoteHas(clone, target)) throw new WorkspaceBlocked(`${repository} has no branch ${target} to open the pull request against, so nothing was pushed and no pull request was opened. A person must create the branch or change branchFrom or pullRequestTo for ${repository} in the config.`);
6874
+ base = target;
6875
+ } else base = (await this.git([
6746
6876
  "symbolic-ref",
6747
6877
  "--short",
6748
6878
  "refs/remotes/origin/HEAD"
@@ -9019,7 +9149,8 @@ function readTool(description, grammar, read) {
9019
9149
  * Policy is checked before any git or GitHub call: live ticket, configured repository, a PR this ticket bound,
9020
9150
  * and delivery.allowAutoMerge. Publish re-checks push permission first and returns `published: false`
9021
9151
  * without any write when it is missing. Then the host review decision may stop it with `review_required`, again without any write.
9022
- * Repository access errors file a failed report.
9152
+ * Repository access errors file a failed report. A configured target branch missing on the remote returns
9153
+ * `published: false` before any push.
9023
9154
  */
9024
9155
  const REVIEW_REASON = "Run the superagent-reviewer subagent before publishing, give it the original request and the human constraints, apply only bug and missing-requirement findings, then call orchestrator_publish again";
9025
9156
  /** The issue a Trigger is on: its meta names a repository and a number, and `isPr` is false. */
@@ -9081,7 +9212,7 @@ function deliveryTools(host, ticketId) {
9081
9212
  return [
9082
9213
  tool$1({
9083
9214
  name: "orchestrator_publish",
9084
- description: "Push this ticket's commits and open its pull request, or reuse the open one for the branch; never force-pushes. Binds the PR thread to this ticket. Returns published: false, pushing nothing, when the agent account has no write access to the repository. Returns published: false with review_required, pushing nothing, when the host requires a review first: run the superagent-reviewer subagent, then publish again.",
9215
+ description: "Push this ticket's commits and open its pull request, or reuse the open one for the branch; never force-pushes. Binds the PR thread to this ticket. Returns published: false, pushing nothing, when the agent account has no write access to the repository. Returns published: false with review_required, pushing nothing, when the host requires a review first: run the superagent-reviewer subagent, then publish again. Returns published: false, pushing nothing, when the configured target branch is missing on the remote.",
9085
9216
  schema: z.object({
9086
9217
  title: z.string().trim().min(1).max(256),
9087
9218
  body: z.string().trim().min(1).max(6e4),
@@ -9122,6 +9253,12 @@ function deliveryTools(host, ticketId) {
9122
9253
  try {
9123
9254
  pushed = await deliveryHost().push(ticketId, { coAuthors: args.coAuthors });
9124
9255
  } catch (error) {
9256
+ if (error instanceof WorkspaceBlocked) return {
9257
+ published: false,
9258
+ repository,
9259
+ reason: error.message,
9260
+ next: "Report blocked with the reason; a person must create the branch or fix the repository config"
9261
+ };
9125
9262
  throw error instanceof RepositoryAccessError ? accessFailure(repository, error) : error;
9126
9263
  }
9127
9264
  let pr;
@@ -10736,7 +10873,7 @@ var Runtime = class {
10736
10873
  });
10737
10874
  };
10738
10875
  const deps = {
10739
- prepare: (ticket) => this.workspaces.prepare(ticket, worktreeName(ticket)),
10876
+ prepare: (ticket) => this.workspaces.prepare(ticket, worktreeName(ticket), branchesOf(this.current, repositoryOf(ticket))),
10740
10877
  instructions: async (ticket) => this.prompts.instructions("worker", [{
10741
10878
  source: "ticket-title",
10742
10879
  text: ticket.title
@@ -11174,6 +11311,8 @@ var Runtime = class {
11174
11311
  const effective = {
11175
11312
  ...this.current,
11176
11313
  repositories: next.repositories,
11314
+ branches: next.branches,
11315
+ review: next.review,
11177
11316
  policy: next.policy,
11178
11317
  network: next.network,
11179
11318
  retention: next.retention,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superagent-claude",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "A proactive agent for GitHub repositories. Powered by Claude.",
5
5
  "license": "MIT",
6
6
  "repository": {