@sjawhar/opencode-legion-envoy 9.8.1 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dispatch.js CHANGED
@@ -13867,6 +13867,21 @@ var MAX_ISSUE_PAGE_LIMIT = 250;
13867
13867
  var DEFAULT_ISSUE_PAGE_LIMIT = 50;
13868
13868
  var ASK_TURNS = ["human", "agent"];
13869
13869
  var DELIVERY_CAPABILITIES = ["aside", "btw", "steer"];
13870
+ var IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
13871
+ var IDEMPOTENCY_REPLAYED_HEADER = "Idempotency-Replayed";
13872
+ var IDEMPOTENCY_KEY_MAX = 128;
13873
+ var IDEMPOTENCY_KEY_PATTERN = new RegExp(`^[A-Za-z0-9._:-]{1,${IDEMPOTENCY_KEY_MAX}}$`);
13874
+ var IDEMPOTENCY_KEY_IN_FLIGHT = "KEY_IN_FLIGHT";
13875
+ var IDEMPOTENCY_KEY_COMMITTED = "KEY_COMMITTED";
13876
+ var IDEMPOTENCY_ANSWER_NOT_KEPT = "ANSWER_NOT_KEPT";
13877
+ function retriesUnderKey(status, code) {
13878
+ return status >= 500 || status === 408 || status === 429 || status === 409 && code === IDEMPOTENCY_KEY_IN_FLIGHT;
13879
+ }
13880
+ function answerNotKept(status, replayed, body) {
13881
+ return replayed && status >= 200 && status < 300 && typeof body === "object" && body !== null && Reflect.get(body, "code") === IDEMPOTENCY_ANSWER_NOT_KEPT;
13882
+ }
13883
+ var IDEMPOTENCY_KEY_TTL_MS = 24 * 60 * 60 * 1000;
13884
+ var IDEMPOTENCY_LEASE_MS = 60 * 1000;
13870
13885
  var DELIVERY_DUPLICATE_WINDOW_MS = 72 * 60 * 60 * 1000;
13871
13886
  var SEARCH_DEGRADED_EMBEDDER_UNAVAILABLE = "embedder_unavailable";
13872
13887
  var IssueDeployedEventPayloadSchema = strictObject({
@@ -15534,6 +15549,43 @@ class DispatchGatewayError extends DispatchServiceError {
15534
15549
  this.mayHaveReachedDispatch = reachesDispatch(status);
15535
15550
  }
15536
15551
  }
15552
+
15553
+ class DispatchWriteUnsettledError extends Error {
15554
+ last;
15555
+ attempts;
15556
+ name = "DispatchWriteUnsettledError";
15557
+ constructor(last, attempts, unanswered) {
15558
+ let answer;
15559
+ let why = "an earlier attempt of the write may have reached Dispatch";
15560
+ if (last instanceof DispatchGatewayError) {
15561
+ answer = last.answer;
15562
+ } else if (last instanceof DispatchServiceError) {
15563
+ answer = `${last.code}: ${last.message}`;
15564
+ if (last.code === IDEMPOTENCY_KEY_IN_FLIGHT) {
15565
+ why = "Dispatch was still running an earlier attempt of the write";
15566
+ }
15567
+ } else {
15568
+ answer = unanswered ?? last.message;
15569
+ why = "an attempt of the write may have reached Dispatch";
15570
+ }
15571
+ const tried = attempts === 1 ? "its only attempt" : `the last of ${attempts} attempts`;
15572
+ super(`${answer} (${tried}), and ${why}, so it may or may not have taken effect: check whether ` + "it did before retrying it.", { cause: last });
15573
+ this.last = last;
15574
+ this.attempts = attempts;
15575
+ }
15576
+ }
15577
+
15578
+ class DispatchWriteLandedError extends Error {
15579
+ code;
15580
+ status;
15581
+ name = "DispatchWriteLandedError";
15582
+ constructor(code, status, method, url) {
15583
+ const lost = code === IDEMPOTENCY_ANSWER_NOT_KEPT ? "Dispatch kept no copy of its answer, which was too large" : "its answer was lost on the way back";
15584
+ super(`${method} ${url} was made, but ${lost} (${code}), so this command cannot print what it ` + "made: read it back with `dispatch read`, or with the matching list for something it created.");
15585
+ this.code = code;
15586
+ this.status = status;
15587
+ }
15588
+ }
15537
15589
  function asErrorShape(value) {
15538
15590
  return typeof value === "object" && value !== null ? value : {};
15539
15591
  }
@@ -15549,6 +15601,100 @@ function requestSignal(signal) {
15549
15601
  const deadline = AbortSignal.timeout(DISPATCH_TOOL_DEADLINE_MS);
15550
15602
  return signal === undefined ? deadline : AbortSignal.any([signal, deadline]);
15551
15603
  }
15604
+ var DISPATCH_WRITE_RETRY = {
15605
+ pausesMs: [1000, 2000, 4000, 8000],
15606
+ withinMs: 30000,
15607
+ silenceMs: 20000
15608
+ };
15609
+ function pauseFor(ms, signal) {
15610
+ const { promise, resolve, reject } = Promise.withResolvers();
15611
+ if (signal.aborted) {
15612
+ reject(signal.reason);
15613
+ return promise;
15614
+ }
15615
+ const stop = () => {
15616
+ clearTimeout(timer);
15617
+ reject(signal.reason);
15618
+ };
15619
+ const timer = setTimeout(() => {
15620
+ signal.removeEventListener("abort", stop);
15621
+ resolve();
15622
+ }, ms);
15623
+ signal.addEventListener("abort", stop, { once: true });
15624
+ return promise;
15625
+ }
15626
+ function retryAfterMs(response) {
15627
+ const value = response.headers.get("Retry-After")?.trim() ?? "";
15628
+ if (/^\d+$/.test(value))
15629
+ return Number(value) * 1000;
15630
+ const at = Date.parse(value);
15631
+ return Number.isNaN(at) ? 0 : Math.max(0, at - Date.now());
15632
+ }
15633
+ var KEYED_LAYER_MEMORY_MS = 5 * 60000;
15634
+ var KEYED_LAYER_SEEN = new Map;
15635
+ var KEYED_LAYER_PROBES = new Map;
15636
+ var KEY_LAYER_PROBE_MS = 5000;
15637
+ function keyEchoed(response, key) {
15638
+ const echoed = response.headers.get(IDEMPOTENCY_KEY_HEADER)?.trim() ?? "";
15639
+ return echoed.replace(/^"(.*)"$/, "$1") === key;
15640
+ }
15641
+ var STREAMED_BODY_MIN = 1 << 20;
15642
+ var STREAMED_BODY_CHUNK = 64 << 10;
15643
+ function attemptBody(body, headers, progress) {
15644
+ const bytes = typeof body.content === "string" ? new TextEncoder().encode(body.content) : body.content;
15645
+ if (bytes.byteLength < STREAMED_BODY_MIN)
15646
+ return { body: body.content, headers };
15647
+ let sent = 0;
15648
+ const stream = new ReadableStream({
15649
+ pull(controller) {
15650
+ progress();
15651
+ if (sent >= bytes.byteLength) {
15652
+ controller.close();
15653
+ return;
15654
+ }
15655
+ const end = Math.min(sent + STREAMED_BODY_CHUNK, bytes.byteLength);
15656
+ controller.enqueue(bytes.subarray(sent, end));
15657
+ sent = end;
15658
+ }
15659
+ });
15660
+ return { body: stream, headers: { ...headers, "Content-Length": String(bytes.byteLength) } };
15661
+ }
15662
+
15663
+ class WriteAttemptSilent extends Error {
15664
+ name = "WriteAttemptSilent";
15665
+ constructor(silenceMs) {
15666
+ super(`made no progress for ${silenceMs / 1000} s, so it was cut off`);
15667
+ }
15668
+ }
15669
+ var NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
15670
+ async function writeAnswer(response, key) {
15671
+ const echoed = keyEchoed(response, key);
15672
+ if (response.ok || NULL_BODY_STATUSES.has(response.status)) {
15673
+ return { response, again: false, reached: false, echoed, unjudged: false };
15674
+ }
15675
+ const { status } = response;
15676
+ const text = await response.text();
15677
+ let shape = {};
15678
+ if (isJson(response)) {
15679
+ try {
15680
+ shape = asErrorShape(JSON.parse(text));
15681
+ } catch {
15682
+ shape = {};
15683
+ }
15684
+ }
15685
+ const again = retriesUnderKey(status, shape.code);
15686
+ return {
15687
+ response: new Response(text, {
15688
+ status,
15689
+ statusText: response.statusText,
15690
+ headers: response.headers
15691
+ }),
15692
+ again,
15693
+ reached: status >= 500 || again && status === 409,
15694
+ echoed,
15695
+ unjudged: !echoed && typeof shape.error === "string"
15696
+ };
15697
+ }
15552
15698
  function whyNotAPage(answer) {
15553
15699
  if (typeof answer === "string") {
15554
15700
  return `text that is not a JSON page, ${GATEWAY_PAGE}, so a retry may succeed.`;
@@ -15633,12 +15779,14 @@ function excerpt(text, token) {
15633
15779
  class DispatchClient {
15634
15780
  token;
15635
15781
  fetchImpl;
15782
+ writeRetry;
15636
15783
  #baseUrl;
15637
15784
  #resolvedIssues = new Map;
15638
15785
  #signal;
15639
- constructor(baseUrl, token, fetchImpl = fetch, signal) {
15786
+ constructor(baseUrl, token, fetchImpl = fetch, signal, writeRetry = DISPATCH_WRITE_RETRY) {
15640
15787
  this.token = token;
15641
15788
  this.fetchImpl = fetchImpl;
15789
+ this.writeRetry = writeRetry;
15642
15790
  this.#baseUrl = baseUrl.replace(/\/+$/, "");
15643
15791
  this.#signal = requestSignal(signal);
15644
15792
  }
@@ -15864,6 +16012,10 @@ class DispatchClient {
15864
16012
  async messageReply(id, input, options = {}) {
15865
16013
  return this.#json("POST", ["api", "v1", "messages", id, "reply"], input, options.followUp === true ? { follow_up: "true" } : undefined);
15866
16014
  }
16015
+ async deliveryReply(resource, id, input) {
16016
+ const collection = resource === "message" ? "messages" : "comments";
16017
+ await this.#json("POST", ["api", "v1", collection, id, "reply"], input);
16018
+ }
15867
16019
  async getMessage(issue, id) {
15868
16020
  return this.#json("GET", [
15869
16021
  "api",
@@ -15891,11 +16043,7 @@ class DispatchClient {
15891
16043
  }
15892
16044
  async fileVersion(id, version) {
15893
16045
  const url = this.#url(["api", "v1", "artifacts", id, "versions", String(version)]);
15894
- const response = await this.fetchImpl(url, {
15895
- method: "GET",
15896
- headers: { Accept: "*/*", Authorization: `Bearer ${this.token}` },
15897
- signal: this.#signal
15898
- });
16046
+ const response = await this.#send("GET", url, undefined, "*/*");
15899
16047
  if (!response.ok)
15900
16048
  return this.#response("GET", url, response);
15901
16049
  return {
@@ -15973,19 +16121,8 @@ class DispatchClient {
15973
16121
  return (await this.#jsonAnswer(method, path, body, query)).payload;
15974
16122
  }
15975
16123
  async#jsonAnswer(method, path, body, query) {
15976
- const headers = {
15977
- Accept: "application/json",
15978
- Authorization: `Bearer ${this.token}`
15979
- };
15980
- if (body !== undefined)
15981
- headers["Content-Type"] = "application/json";
15982
16124
  const url = this.#url(path, query);
15983
- const response = await this.fetchImpl(url, {
15984
- method,
15985
- headers,
15986
- signal: this.#signal,
15987
- ...body === undefined ? {} : { body: JSON.stringify(body) }
15988
- });
16125
+ const response = await this.#send(method, url, body === undefined ? undefined : { content: JSON.stringify(body), type: "application/json" });
15989
16126
  return { status: response.status, payload: await this.#response(method, url, response) };
15990
16127
  }
15991
16128
  async#upload(path, input) {
@@ -16000,16 +16137,152 @@ class DispatchClient {
16000
16137
  form.set("file", input.file, input.name);
16001
16138
  return this.#form("POST", path, form);
16002
16139
  }
16003
- async#form(method, path, body) {
16140
+ async#form(method, path, form) {
16004
16141
  const url = this.#url(path);
16005
- const response = await this.fetchImpl(url, {
16006
- method,
16007
- headers: { Accept: "application/json", Authorization: `Bearer ${this.token}` },
16008
- body,
16009
- signal: this.#signal
16010
- });
16142
+ const encoded = new Response(form);
16143
+ const type = encoded.headers.get("Content-Type") ?? "multipart/form-data";
16144
+ const content = new Uint8Array(await encoded.arrayBuffer());
16145
+ const response = await this.#send(method, url, { content, type });
16011
16146
  return this.#response(method, url, response);
16012
16147
  }
16148
+ async#send(method, url, body, accept = "application/json") {
16149
+ const headers = {
16150
+ Accept: accept,
16151
+ Authorization: `Bearer ${this.token}`
16152
+ };
16153
+ if (body !== undefined)
16154
+ headers["Content-Type"] = body.type;
16155
+ if (method !== "POST" && method !== "PATCH") {
16156
+ return this.fetchImpl(url, {
16157
+ method,
16158
+ headers,
16159
+ signal: this.#signal,
16160
+ ...body === undefined ? {} : { body: body.content }
16161
+ });
16162
+ }
16163
+ const key = crypto.randomUUID();
16164
+ headers[IDEMPOTENCY_KEY_HEADER] = key;
16165
+ const { pausesMs, withinMs, silenceMs, pause = pauseFor } = this.writeRetry;
16166
+ const request = `${method} ${url}`;
16167
+ const started = this.#now();
16168
+ let reached = false;
16169
+ for (let attempt = 1;; attempt++) {
16170
+ let answer;
16171
+ let thrown;
16172
+ try {
16173
+ answer = await writeAnswer(await this.#attempt(url, method, headers, body, silenceMs), key);
16174
+ if (answer.echoed)
16175
+ KEYED_LAYER_SEEN.set(this.#baseUrl, this.#now());
16176
+ if (!answer.again)
16177
+ return answer.response;
16178
+ reached ||= answer.reached;
16179
+ } catch (error) {
16180
+ const name = error instanceof Error || error instanceof DOMException ? error.name : "";
16181
+ if (!(error instanceof WriteAttemptSilent) && (this.#signal.aborted || name === "AbortError" || name === "TimeoutError")) {
16182
+ throw this.#ended(error, request, attempt, true);
16183
+ }
16184
+ thrown = error;
16185
+ reached = true;
16186
+ }
16187
+ const scheduled = pausesMs[attempt - 1];
16188
+ const wait = Math.max(scheduled ?? 0, answer === undefined ? 0 : retryAfterMs(answer.response));
16189
+ const inTime = scheduled !== undefined && this.#now() - started + wait <= withinMs;
16190
+ if (!inTime || !(answer?.echoed || await this.#runsKeyedLayer(answer?.unjudged ?? false))) {
16191
+ if (answer !== undefined) {
16192
+ return this.#unsettled(method, url, answer.response, reached, attempt);
16193
+ }
16194
+ if (thrown instanceof WriteAttemptSilent) {
16195
+ throw new DispatchWriteUnsettledError(thrown, attempt, `${request} ${thrown.message}`);
16196
+ }
16197
+ throw thrown;
16198
+ }
16199
+ await answer?.response.body?.cancel().catch(() => {
16200
+ return;
16201
+ });
16202
+ try {
16203
+ await pause(wait, this.#signal);
16204
+ } catch (error) {
16205
+ throw this.#ended(error, request, attempt, reached);
16206
+ }
16207
+ }
16208
+ }
16209
+ #now() {
16210
+ return this.writeRetry.now?.() ?? performance.now();
16211
+ }
16212
+ async#attempt(url, method, headers, body, silenceMs) {
16213
+ const cut = new AbortController;
16214
+ const silent = () => cut.abort(new WriteAttemptSilent(silenceMs));
16215
+ let timer = setTimeout(silent, silenceMs);
16216
+ let answered = false;
16217
+ const progress = () => {
16218
+ if (answered)
16219
+ return;
16220
+ clearTimeout(timer);
16221
+ timer = setTimeout(silent, silenceMs);
16222
+ };
16223
+ try {
16224
+ return await this.fetchImpl(url, {
16225
+ method,
16226
+ signal: AbortSignal.any([this.#signal, cut.signal]),
16227
+ ...body === undefined ? { headers } : attemptBody(body, headers, progress)
16228
+ });
16229
+ } catch (error) {
16230
+ if (cut.signal.aborted && !this.#signal.aborted)
16231
+ throw cut.signal.reason;
16232
+ throw error;
16233
+ } finally {
16234
+ answered = true;
16235
+ clearTimeout(timer);
16236
+ }
16237
+ }
16238
+ #ended(error, request, attempts, mayHaveReached) {
16239
+ const reason = this.#signal.aborted ? this.#signal.reason : error;
16240
+ const timedOut = (reason instanceof Error || reason instanceof DOMException) && reason.name === "TimeoutError";
16241
+ if (!timedOut || !mayHaveReached || !(error instanceof Error))
16242
+ return error;
16243
+ return new DispatchWriteUnsettledError(error, attempts, `${request} had no definitive answer when its deadline ended it`);
16244
+ }
16245
+ async#runsKeyedLayer(unjudged) {
16246
+ const origin = this.#baseUrl;
16247
+ if (unjudged)
16248
+ KEYED_LAYER_SEEN.delete(origin);
16249
+ const seen = KEYED_LAYER_SEEN.get(origin);
16250
+ if (seen !== undefined && this.#now() - seen < KEYED_LAYER_MEMORY_MS)
16251
+ return true;
16252
+ let probe = KEYED_LAYER_PROBES.get(origin);
16253
+ if (probe === undefined) {
16254
+ probe = this.#probeKeyedLayer().finally(() => KEYED_LAYER_PROBES.delete(origin));
16255
+ KEYED_LAYER_PROBES.set(origin, probe);
16256
+ }
16257
+ const runs = await probe;
16258
+ if (runs)
16259
+ KEYED_LAYER_SEEN.set(origin, this.#now());
16260
+ return runs;
16261
+ }
16262
+ async#probeKeyedLayer() {
16263
+ try {
16264
+ const response = await this.fetchImpl(`${this.#baseUrl}/healthz`, {
16265
+ method: "GET",
16266
+ headers: { Accept: "application/json", Authorization: `Bearer ${this.token}` },
16267
+ signal: AbortSignal.any([this.#signal, AbortSignal.timeout(KEY_LAYER_PROBE_MS)])
16268
+ });
16269
+ if (!isJson(response))
16270
+ return false;
16271
+ const health = await response.json();
16272
+ return typeof health === "object" && health !== null && Reflect.get(health, "idempotency_keys") === true;
16273
+ } catch {
16274
+ return false;
16275
+ }
16276
+ }
16277
+ async#unsettled(method, url, response, reached, attempts) {
16278
+ const refusal = await this.#response(method, url, response).catch((error) => error);
16279
+ if (reached && refusal instanceof DispatchServiceError) {
16280
+ const landedAlone = refusal instanceof DispatchGatewayError ? refusal.mayHaveReachedDispatch : refusal.status >= 500;
16281
+ if (!landedAlone)
16282
+ throw new DispatchWriteUnsettledError(refusal, attempts);
16283
+ }
16284
+ throw refusal;
16285
+ }
16013
16286
  #url(path, query) {
16014
16287
  const url = new URL(`${path.map((segment) => encodeURIComponent(segment)).join("/")}`, `${this.#baseUrl}/`);
16015
16288
  if (query) {
@@ -16037,6 +16310,9 @@ class DispatchClient {
16037
16310
  if (!response.ok) {
16038
16311
  const error = asErrorShape(payload);
16039
16312
  if (typeof error.error === "string") {
16313
+ if (response.status === 409 && error.code === IDEMPOTENCY_KEY_COMMITTED) {
16314
+ throw new DispatchWriteLandedError(IDEMPOTENCY_KEY_COMMITTED, response.status, method, url);
16315
+ }
16040
16316
  throw new DispatchServiceError(error.code ?? `HTTP_${response.status}`, response.status, error.error, error.candidates, error.current, error.mismatches);
16041
16317
  }
16042
16318
  const quoted = excerpt(text, this.token);
@@ -16054,6 +16330,10 @@ class DispatchClient {
16054
16330
  const reason = reasonPhrase === "" ? "" : ` ${reasonPhrase}`;
16055
16331
  throw new DispatchGatewayError(response.status, `${method} ${url} answered ${response.status}${reason} with ${body}, ${GATEWAY_PAGE}`, gatewayAdvice(method, response.status));
16056
16332
  }
16333
+ const replayed = response.headers.get(IDEMPOTENCY_REPLAYED_HEADER)?.trim() === "?1";
16334
+ if (answerNotKept(response.status, replayed, payload)) {
16335
+ throw new DispatchWriteLandedError(IDEMPOTENCY_ANSWER_NOT_KEPT, response.status, method, url);
16336
+ }
16057
16337
  return payload;
16058
16338
  }
16059
16339
  }
@@ -18349,7 +18629,7 @@ async function executeDispatchTool(input) {
18349
18629
  throw new ToolInputError(input.tool, checked.problems);
18350
18630
  const ownerArguments = checked.owner;
18351
18631
  const pictures = checked.pictures;
18352
- const dispatchClient = () => new DispatchClient(configUrl, configToken, fetchImpl, input.signal);
18632
+ const dispatchClient = () => new DispatchClient(configUrl, configToken, fetchImpl, input.signal, input.writeRetry);
18353
18633
  if (input.tool === "dispatch_open_asks") {
18354
18634
  const client = dispatchClient();
18355
18635
  const project = optionalString(ownerArguments.args, "project");
@@ -18405,7 +18685,11 @@ async function executeDispatchTool(input) {
18405
18685
  throw new Error("issue or project is required");
18406
18686
  return owner;
18407
18687
  };
18408
- const postWithPictures = (text, limit, owner, upload) => textWithPictures(input.tool, text, pictures, limit, owner, upload, actor);
18688
+ const postWithPictures = (text, limit, owner, upload) => textWithPictures(input.tool, text, pictures, limit, owner, (picture) => upload(picture).catch((error) => {
18689
+ if (!(error instanceof DispatchWriteLandedError))
18690
+ throw error;
18691
+ throw new Error(`${error.message} That was a picture uploaded before this call's own write, so nothing else was written: run the call again, which uploads it as its next version.`, { cause: error });
18692
+ }), actor);
18409
18693
  const writePictures = (resolved, text, limit) => {
18410
18694
  if (resolved?.owner.kind === "project") {
18411
18695
  const { project } = resolved.owner;
@@ -18517,6 +18801,9 @@ async function executeDispatchTool(input) {
18517
18801
  ref: dispatchChildRef(dispatchIssueRef(issueKey), "message", message.id)
18518
18802
  };
18519
18803
  } catch (error) {
18804
+ if (error instanceof DispatchWriteLandedError) {
18805
+ throw new Error(`${error.message} That was the closing reason, so the close was not sent: read the issue's messages for it, then close with a reason that points at it, since retrying this call posts its reason again.`, { cause: error });
18806
+ }
18520
18807
  const told = writeMayHaveLanded(error) ? "the reason may or may not have been posted, and the close was not sent: read the issue's messages before retrying, since retrying this call posts its reason again" : "the reason was not posted, so the close was not sent";
18521
18808
  if (error instanceof DispatchServiceError)
18522
18809
  throw refusalWithCode(error, told);
@@ -18547,6 +18834,8 @@ async function executeDispatchTool(input) {
18547
18834
  actor
18548
18835
  });
18549
18836
  } catch (error) {
18837
+ if (error instanceof DispatchWriteLandedError)
18838
+ throw error;
18550
18839
  const taken = dispatchAnswered(error, 500) && newLinks.length > 0 ? `one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
18551
18840
  if (closingNote === undefined)
18552
18841
  throw refusalWithCode(error, taken);
@@ -20716,8 +21005,13 @@ async function runDispatchCli(argv, rawEnv, io) {
20716
21005
  code = OK;
20717
21006
  } catch (error) {
20718
21007
  text = messageFor(error);
20719
- entry = { tool, error: text };
20720
- code = REFUSED;
21008
+ if (error instanceof DispatchWriteLandedError) {
21009
+ entry = { tool, details: { landed_without_answer: error.code } };
21010
+ code = OK;
21011
+ } else {
21012
+ entry = { tool, error: text };
21013
+ code = REFUSED;
21014
+ }
20721
21015
  }
20722
21016
  try {
20723
21017
  recordOutcome(dir, sessionId, follows, entry);
@@ -13568,6 +13568,10 @@ var MAX_ISSUE_PAGE_LIMIT = 250;
13568
13568
  var DEFAULT_ISSUE_PAGE_LIMIT = 50;
13569
13569
  var ASK_TURNS = ["human", "agent"];
13570
13570
  var DELIVERY_CAPABILITIES = ["aside", "btw", "steer"];
13571
+ var IDEMPOTENCY_KEY_MAX = 128;
13572
+ var IDEMPOTENCY_KEY_PATTERN = new RegExp(`^[A-Za-z0-9._:-]{1,${IDEMPOTENCY_KEY_MAX}}$`);
13573
+ var IDEMPOTENCY_KEY_TTL_MS = 24 * 60 * 60 * 1000;
13574
+ var IDEMPOTENCY_LEASE_MS = 60 * 1000;
13571
13575
  var DELIVERY_DUPLICATE_WINDOW_MS = 72 * 60 * 60 * 1000;
13572
13576
  var IssueDeployedEventPayloadSchema = strictObject({
13573
13577
  repo: string2(),
@@ -15586,7 +15590,7 @@ function sourceSession(sessionID) {
15586
15590
  // src/server.ts
15587
15591
  import { tool } from "@opencode-ai/plugin/tool";
15588
15592
  // package.json
15589
- var version2 = "9.8.1";
15593
+ var version2 = "11.0.0";
15590
15594
 
15591
15595
  // src/log.ts
15592
15596
  import { appendFile, mkdir } from "fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "9.8.1",
3
+ "version": "11.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/src/server.js",
@@ -20,6 +20,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
20
20
  ## Stop rules
21
21
 
22
22
  - **A refused completion is information, not a retry loop.** Quote the answer verbatim to the architect, so "my work is lost" and "my work belongs to an ended run" read differently. `HANDOFF_STALE_GENERATION` (the issue is on a newer run: your turn began before the daemon's task for that run reached you, and the answer names that task when it is queued) or `HANDOFF_NO_RUN` (your claim holds no task): push nothing further, end the turn without completing, and tell the architect; if the task then arrives (none may: the new run's phase can be another claim's) and its work is already committed, call `handoff_complete` again at the same commit in that task's turn, and never redo the work or write a second handoff. `HANDOFF_NOT_CURRENT_PHASE`: the issue left your phase; report, never complete again. `HANDOFF_ALREADY_RECORDED`: the first answer stands, an acceptance or one of the refusals the daemon records (`HANDOFF_NOT_CURRENT_PHASE`, `READY_REQUIRED`, `HANDOFF_NOT_NEW`, and `HANDOFF_STALE_GENERATION` only when the issue moved during the call); if you did not see it, tell the architect. A refusal given before any fact is applied (`HANDOFF_STALE_GENERATION`, `HANDOFF_NO_RUN` and READY's checks of the head among them) records nothing, so the corrected call at the same commit is applied. A reminder is not a task: after an accepted completion, a second call is refused.
23
+ - **A turn that ends with your phase open is reminded**, at the end of every turn for as long as the phase stays open. Answer by completing, or with a WAITING line naming what you wait on, which buys a quiet wait; a run of reminders that change nothing tells your architect.
23
24
  - **After your phase completes, the workspace is read-only for you**: no commit, no `jj new`, no edit to a tracked file, in any later turn until a new task names your phase. The active phase owns it, and you stay live only to answer questions.
24
25
  - **Never start another Legion role**: the daemon starts every phase worker.
25
26
  - **Never end your turn blocked on a decision someone else owns.** Send a product, scope, design, lifecycle or cross-phase decision to the owning architect as your role prompt says, with the issue, phase, verified observation, what you tried and the decision needed; a human-only to-do is a `dispatch ask`. Otherwise proceed: a phase that stops silently holds its issue until someone notices.