@sjawhar/opencode-legion-envoy 12.0.1 → 12.2.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
|
@@ -14688,7 +14688,7 @@ var dispatchToolSpecs = [
|
|
|
14688
14688
|
{
|
|
14689
14689
|
name: "dispatch_doc_read",
|
|
14690
14690
|
example: { issue: "DSP-1" },
|
|
14691
|
-
description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
|
|
14691
|
+
description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, time, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
|
|
14692
14692
|
arguments: (z) => ({
|
|
14693
14693
|
issue: z.string().describe(ISSUE_REFERENCE).optional(),
|
|
14694
14694
|
project: z.string().describe("Project key owning the document.").optional(),
|
|
@@ -14848,6 +14848,13 @@ var dispatchToolSpecs = [
|
|
|
14848
14848
|
}),
|
|
14849
14849
|
strict: true
|
|
14850
14850
|
},
|
|
14851
|
+
{
|
|
14852
|
+
name: "dispatch_answers",
|
|
14853
|
+
example: {},
|
|
14854
|
+
description: "List every answer and comment a person wrote to this session in the last 72 hours, newest first, each in full: " + "answers to its asks, replies on its asks and in its comment threads, comments on its documents and on issues routed to it, " + "mentions of it, messages to it and replies to its messages. Each item names its time, its writer, what it is, its " + "dispatch:// reference and its issue or document, an answer's question and choices, and the words, never cut. " + "Run it when the list of people's answers on a model request says it left older items out. " + "It reads this session's id from CLAUDE_CODE_SESSION_ID under Claude Code and DISPATCH_SESSION_ID elsewhere, so a subagent reads its parent's.",
|
|
14855
|
+
arguments: () => ({}),
|
|
14856
|
+
strict: true
|
|
14857
|
+
},
|
|
14851
14858
|
{
|
|
14852
14859
|
name: "dispatch_whoami",
|
|
14853
14860
|
example: {},
|
|
@@ -15961,6 +15968,12 @@ class DispatchClient {
|
|
|
15961
15968
|
async openAsksForProject(project) {
|
|
15962
15969
|
return this.#json("GET", ["api", "v1", "asks", "open"], undefined, { project });
|
|
15963
15970
|
}
|
|
15971
|
+
async sessionAnswers(sessionID, options = {}) {
|
|
15972
|
+
return this.#json("GET", ["api", "v1", "agents", sessionID, "answers"], undefined, {
|
|
15973
|
+
...options.since === undefined ? {} : { since: options.since },
|
|
15974
|
+
...options.maxChars === undefined ? {} : { max_chars: options.maxChars }
|
|
15975
|
+
}, options.signal);
|
|
15976
|
+
}
|
|
15964
15977
|
async listAgents() {
|
|
15965
15978
|
return this.#json("GET", ["api", "v1", "agents"]);
|
|
15966
15979
|
}
|
|
@@ -16189,12 +16202,12 @@ class DispatchClient {
|
|
|
16189
16202
|
throw error;
|
|
16190
16203
|
}
|
|
16191
16204
|
}
|
|
16192
|
-
async#json(method, path, body, query) {
|
|
16193
|
-
return (await this.#jsonAnswer(method, path, body, query)).payload;
|
|
16205
|
+
async#json(method, path, body, query, signal) {
|
|
16206
|
+
return (await this.#jsonAnswer(method, path, body, query, signal)).payload;
|
|
16194
16207
|
}
|
|
16195
|
-
async#jsonAnswer(method, path, body, query) {
|
|
16208
|
+
async#jsonAnswer(method, path, body, query, signal) {
|
|
16196
16209
|
const url = this.#url(path, query);
|
|
16197
|
-
const response = await this.#send(method, url, body === undefined ? undefined : { content: JSON.stringify(body), type: "application/json" });
|
|
16210
|
+
const response = await this.#send(method, url, body === undefined ? undefined : { content: JSON.stringify(body), type: "application/json" }, undefined, signal);
|
|
16198
16211
|
return { status: response.status, payload: await this.#response(method, url, response) };
|
|
16199
16212
|
}
|
|
16200
16213
|
async#upload(path, input) {
|
|
@@ -16217,7 +16230,7 @@ class DispatchClient {
|
|
|
16217
16230
|
const response = await this.#send(method, url, { content, type });
|
|
16218
16231
|
return this.#response(method, url, response);
|
|
16219
16232
|
}
|
|
16220
|
-
async#send(method, url, body, accept = "application/json") {
|
|
16233
|
+
async#send(method, url, body, accept = "application/json", signal) {
|
|
16221
16234
|
const headers = {
|
|
16222
16235
|
Accept: accept,
|
|
16223
16236
|
Authorization: `Bearer ${this.token}`
|
|
@@ -16228,7 +16241,7 @@ class DispatchClient {
|
|
|
16228
16241
|
return this.fetchImpl(url, {
|
|
16229
16242
|
method,
|
|
16230
16243
|
headers,
|
|
16231
|
-
signal: this.#signal,
|
|
16244
|
+
signal: signal === undefined ? this.#signal : AbortSignal.any([this.#signal, signal]),
|
|
16232
16245
|
...body === undefined ? {} : { body: body.content }
|
|
16233
16246
|
});
|
|
16234
16247
|
}
|
|
@@ -16414,6 +16427,9 @@ class DispatchClient {
|
|
|
16414
16427
|
function documentLabel(project, slug) {
|
|
16415
16428
|
return `${project}/${slug}`;
|
|
16416
16429
|
}
|
|
16430
|
+
function ownerTitle(owner) {
|
|
16431
|
+
return "issue" in owner ? `${owner.issue.key}: ${owner.issue.title}` : `${owner.document.project} / ${owner.document.name}`;
|
|
16432
|
+
}
|
|
16417
16433
|
function issueTopic(key) {
|
|
16418
16434
|
return { label: key, topic: dispatchIssueSubject(key, ">") };
|
|
16419
16435
|
}
|
|
@@ -17227,6 +17243,99 @@ function notMadeLine(reason, context, searchQuery) {
|
|
|
17227
17243
|
return `No related-item check was made: ${reason}. Search before you rely on this being new: dispatch search --project ${context.project}${query}`;
|
|
17228
17244
|
}
|
|
17229
17245
|
|
|
17246
|
+
// ../envoy-client/src/dispatch-session-directory.ts
|
|
17247
|
+
import { randomUUID } from "crypto";
|
|
17248
|
+
import { renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
17249
|
+
import { homedir } from "os";
|
|
17250
|
+
import { join as join2 } from "path";
|
|
17251
|
+
var SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
17252
|
+
var PRIVATE_FILE = 384;
|
|
17253
|
+
function sessionStateRoot(env) {
|
|
17254
|
+
const stateDir = env["DISPATCH_STATE_DIR"];
|
|
17255
|
+
if (stateDir)
|
|
17256
|
+
return stateDir;
|
|
17257
|
+
return join2(env["XDG_STATE_HOME"] || join2(homedir(), ".local", "state"), "dispatch");
|
|
17258
|
+
}
|
|
17259
|
+
function sessionDirectory(env, sessionId) {
|
|
17260
|
+
if (!SESSION_ID.test(sessionId) || sessionId === "." || sessionId === "..") {
|
|
17261
|
+
throw new Error(`dispatch: the session id ${JSON.stringify(sessionId)} is not 1-128 letters, digits, ".", "_" or "-"`);
|
|
17262
|
+
}
|
|
17263
|
+
return join2(sessionStateRoot(env), "sessions", sessionId);
|
|
17264
|
+
}
|
|
17265
|
+
function writeAtomically(path, text) {
|
|
17266
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
17267
|
+
try {
|
|
17268
|
+
writeFileSync2(temporary, text, { mode: PRIVATE_FILE });
|
|
17269
|
+
renameSync2(temporary, path);
|
|
17270
|
+
} catch (error) {
|
|
17271
|
+
rmSync2(temporary, { force: true });
|
|
17272
|
+
throw error;
|
|
17273
|
+
}
|
|
17274
|
+
}
|
|
17275
|
+
|
|
17276
|
+
// ../envoy-client/src/session-answers.ts
|
|
17277
|
+
var SESSION_ANSWERS_WINDOW_MS = 72 * 3600000;
|
|
17278
|
+
var SESSION_ANSWERS_MAX_CHARS = 8000;
|
|
17279
|
+
var NO_ROUTE_BACKOFF_MS = 5 * 60000;
|
|
17280
|
+
var QUESTION_MAX = 200;
|
|
17281
|
+
var RELATION_PHRASES = {
|
|
17282
|
+
answered_your_ask: "answered your ask",
|
|
17283
|
+
replied_on_your_ask: "replied on your ask",
|
|
17284
|
+
replied_in_a_comment_thread: "replied in a comment thread",
|
|
17285
|
+
commented_on_a_document: "commented on a document",
|
|
17286
|
+
commented_on_an_issue: "commented on an issue",
|
|
17287
|
+
mentioned_you: "mentioned you",
|
|
17288
|
+
sent_you_a_message: "sent you a message",
|
|
17289
|
+
replied_to_your_message: "replied to your message"
|
|
17290
|
+
};
|
|
17291
|
+
function isNoAnswersRoute(error) {
|
|
17292
|
+
return error instanceof DispatchServiceError && error.status === 404;
|
|
17293
|
+
}
|
|
17294
|
+
function renderSessionAnswerItem(item) {
|
|
17295
|
+
const relation = `${RELATION_PHRASES[item.relation]}${item.replaced === true ? " (replaces an earlier answer)" : ""}`;
|
|
17296
|
+
const head = [item.at, `user ${item.author.id}`, relation];
|
|
17297
|
+
if (item.edited_at !== undefined)
|
|
17298
|
+
head.push(`edited ${item.edited_at}`);
|
|
17299
|
+
const owner = item.owner === null ? undefined : ownerTitle(item.owner);
|
|
17300
|
+
const where = item.ref === null ? `Direct message ${item.id}, on no issue` : owner === undefined ? item.ref : `${item.ref} \xB7 ${owner}`;
|
|
17301
|
+
const lines = [head.join(" \xB7 "), where];
|
|
17302
|
+
if (item.ask !== undefined) {
|
|
17303
|
+
const question = item.ask.question.replace(/\s+/g, " ").trim();
|
|
17304
|
+
lines.push(`Question: ${question.length > QUESTION_MAX ? `${question.slice(0, QUESTION_MAX)}\u2026` : question}`);
|
|
17305
|
+
}
|
|
17306
|
+
if (item.kind === "answer") {
|
|
17307
|
+
const selected = item.selected ?? [];
|
|
17308
|
+
lines.push(`Selected: ${selected.length === 0 ? "none" : selected.join(", ")}`);
|
|
17309
|
+
}
|
|
17310
|
+
if (item.text !== "")
|
|
17311
|
+
lines.push(item.text);
|
|
17312
|
+
return lines.join(`
|
|
17313
|
+
`);
|
|
17314
|
+
}
|
|
17315
|
+
function withinWindow(items, now) {
|
|
17316
|
+
const start = now - SESSION_ANSWERS_WINDOW_MS;
|
|
17317
|
+
return items.filter((item) => Date.parse(item.at) >= start);
|
|
17318
|
+
}
|
|
17319
|
+
var MAX_CHARS_TEXT = SESSION_ANSWERS_MAX_CHARS.toLocaleString("en-US");
|
|
17320
|
+
function renderSessionAnswersList(items, options) {
|
|
17321
|
+
const shown = withinWindow(items, Number(options.now));
|
|
17322
|
+
if (shown.length === 0)
|
|
17323
|
+
return "No person wrote to this session in the last 72 hours.";
|
|
17324
|
+
return [
|
|
17325
|
+
`${shown.length} ${shown.length === 1 ? "item" : "items"} people wrote to this session in the last 72 hours, newest first, word for word:`,
|
|
17326
|
+
...shown.map(renderSessionAnswerItem)
|
|
17327
|
+
].join(`
|
|
17328
|
+
|
|
17329
|
+
`);
|
|
17330
|
+
}
|
|
17331
|
+
var STATE_KEY = Symbol.for("legion.pi-envoy.session-answers.v2");
|
|
17332
|
+
var processGlobal = globalThis;
|
|
17333
|
+
var state = processGlobal[STATE_KEY] ?? {
|
|
17334
|
+
sessions: new Map,
|
|
17335
|
+
noRouteUntil: new Map
|
|
17336
|
+
};
|
|
17337
|
+
processGlobal[STATE_KEY] = state;
|
|
17338
|
+
|
|
17230
17339
|
// ../envoy-client/src/dispatch-execute.ts
|
|
17231
17340
|
function documentTopic(artifact) {
|
|
17232
17341
|
return documentTopicOf(artifact.project, artifact.slug);
|
|
@@ -17349,6 +17458,7 @@ var issueFreeTools = {
|
|
|
17349
17458
|
dispatch_project: true,
|
|
17350
17459
|
dispatch_projects: true,
|
|
17351
17460
|
dispatch_open_asks: true,
|
|
17461
|
+
dispatch_answers: true,
|
|
17352
17462
|
dispatch_whoami: true,
|
|
17353
17463
|
dispatch_architecture_sync: true
|
|
17354
17464
|
};
|
|
@@ -18461,12 +18571,9 @@ function openAskAge(ageSeconds) {
|
|
|
18461
18571
|
const days = Math.floor(hours / 24);
|
|
18462
18572
|
return `${days}d${hours % 24 === 0 ? "" : ` ${hours % 24}h`}`;
|
|
18463
18573
|
}
|
|
18464
|
-
function openAskOwner(ask) {
|
|
18465
|
-
return "issue" in ask.owner ? `${ask.owner.issue.key}: ${ask.owner.issue.title}` : `${ask.owner.document.project} / ${ask.owner.document.name}`;
|
|
18466
|
-
}
|
|
18467
18574
|
function openAskLine(ask, baseUrl) {
|
|
18468
18575
|
const priority = ask.priority === null ? "" : `P${ask.priority} \xB7 `;
|
|
18469
|
-
return `- ${openAskAge(ask.age_seconds)} \xB7 ${priority}${
|
|
18576
|
+
return `- ${openAskAge(ask.age_seconds)} \xB7 ${priority}${ownerTitle(ask.owner)} \xB7 ${ask.question} \xB7 ${new URL(ask.ref, baseUrl).toString()}`;
|
|
18470
18577
|
}
|
|
18471
18578
|
function formatOpenAsksSummary(response, baseUrl) {
|
|
18472
18579
|
const scope = "active open asks you authored on open issues and project documents";
|
|
@@ -18882,6 +18989,24 @@ async function executeDispatchTool(input) {
|
|
|
18882
18989
|
const response = await client.openAsks(sessionId);
|
|
18883
18990
|
return { text: formatOpenAsksSummary(response, linkBase), details: { ...response } };
|
|
18884
18991
|
}
|
|
18992
|
+
if (input.tool === "dispatch_answers") {
|
|
18993
|
+
const sessionId = input.sessionId?.trim();
|
|
18994
|
+
if (!sessionId)
|
|
18995
|
+
throw new Error("host session id is required for dispatch answers");
|
|
18996
|
+
let response;
|
|
18997
|
+
try {
|
|
18998
|
+
response = await dispatchClient().sessionAnswers(sessionId);
|
|
18999
|
+
} catch (error) {
|
|
19000
|
+
if (isNoAnswersRoute(error)) {
|
|
19001
|
+
throw new Error(`This Dispatch (${configUrl}) has no answers route yet: its build is older than the answers route.`);
|
|
19002
|
+
}
|
|
19003
|
+
throw error;
|
|
19004
|
+
}
|
|
19005
|
+
return {
|
|
19006
|
+
text: renderSessionAnswersList(response.items, { now: Date.now() }),
|
|
19007
|
+
details: { session: sessionId, since: response.since, total: response.total }
|
|
19008
|
+
};
|
|
19009
|
+
}
|
|
18885
19010
|
if (input.tool === "dispatch_whoami") {
|
|
18886
19011
|
const sessionId = input.sessionId?.trim();
|
|
18887
19012
|
if (!sessionId)
|
|
@@ -19939,16 +20064,13 @@ import {
|
|
|
19939
20064
|
readdirSync as readdirSync2,
|
|
19940
20065
|
readFileSync as readFileSync2,
|
|
19941
20066
|
readSync,
|
|
19942
|
-
|
|
19943
|
-
rmSync as rmSync2,
|
|
20067
|
+
rmSync as rmSync3,
|
|
19944
20068
|
statSync as statSync2,
|
|
19945
|
-
writeFileSync as
|
|
20069
|
+
writeFileSync as writeFileSync3
|
|
19946
20070
|
} from "fs";
|
|
19947
|
-
import {
|
|
19948
|
-
import { join as join2 } from "path";
|
|
19949
|
-
var SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
20071
|
+
import { join as join3 } from "path";
|
|
19950
20072
|
var PRIVATE_DIR = 448;
|
|
19951
|
-
var
|
|
20073
|
+
var PRIVATE_FILE2 = 384;
|
|
19952
20074
|
var IDLE_SESSION_MS = 14 * 86400000;
|
|
19953
20075
|
var PRUNE_INTERVAL_MS = 86400000;
|
|
19954
20076
|
var PRUNED_STAMP = ".pruned";
|
|
@@ -19958,18 +20080,6 @@ var PICTURE_EXTENSIONS = {
|
|
|
19958
20080
|
"image/gif": "gif",
|
|
19959
20081
|
"image/webp": "webp"
|
|
19960
20082
|
};
|
|
19961
|
-
function sessionStateRoot(env) {
|
|
19962
|
-
const stateDir = env["DISPATCH_STATE_DIR"];
|
|
19963
|
-
if (stateDir)
|
|
19964
|
-
return stateDir;
|
|
19965
|
-
return join2(env["XDG_STATE_HOME"] || join2(homedir(), ".local", "state"), "dispatch");
|
|
19966
|
-
}
|
|
19967
|
-
function sessionDirectory(env, sessionId) {
|
|
19968
|
-
if (!SESSION_ID.test(sessionId) || sessionId === "." || sessionId === "..") {
|
|
19969
|
-
throw new Error(`dispatch: the session id ${JSON.stringify(sessionId)} is not 1-128 letters, digits, ".", "_" or "-"`);
|
|
19970
|
-
}
|
|
19971
|
-
return join2(sessionStateRoot(env), "sessions", sessionId);
|
|
19972
|
-
}
|
|
19973
20083
|
function ensureDirectory(dir) {
|
|
19974
20084
|
mkdirSync2(dir, { recursive: true, mode: PRIVATE_DIR });
|
|
19975
20085
|
}
|
|
@@ -19982,16 +20092,11 @@ function readIfPresent(path) {
|
|
|
19982
20092
|
throw error;
|
|
19983
20093
|
}
|
|
19984
20094
|
}
|
|
19985
|
-
function writeAtomically(path, text) {
|
|
19986
|
-
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
19987
|
-
writeFileSync2(temporary, text, { mode: PRIVATE_FILE });
|
|
19988
|
-
renameSync2(temporary, path);
|
|
19989
|
-
}
|
|
19990
20095
|
function stringList(value) {
|
|
19991
20096
|
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
19992
20097
|
}
|
|
19993
20098
|
function loadSessionMemory(dir, sessionId) {
|
|
19994
|
-
const path =
|
|
20099
|
+
const path = join3(dir, "state.json");
|
|
19995
20100
|
const text = readIfPresent(path);
|
|
19996
20101
|
if (text === undefined)
|
|
19997
20102
|
return new Set;
|
|
@@ -20015,7 +20120,7 @@ function writeMemory(dir, sessionId, follows) {
|
|
|
20015
20120
|
pictures: [...shownPictures(sessionId)],
|
|
20016
20121
|
follows: [...follows]
|
|
20017
20122
|
};
|
|
20018
|
-
writeAtomically(
|
|
20123
|
+
writeAtomically(join3(dir, "state.json"), `${JSON.stringify(state)}
|
|
20019
20124
|
`);
|
|
20020
20125
|
}
|
|
20021
20126
|
function appendResult(dir, entry) {
|
|
@@ -20024,8 +20129,8 @@ function appendResult(dir, entry) {
|
|
|
20024
20129
|
}
|
|
20025
20130
|
function writeResult(dir, entry) {
|
|
20026
20131
|
const line = { at: new Date().toISOString(), ...entry };
|
|
20027
|
-
appendFileSync(
|
|
20028
|
-
`, { mode:
|
|
20132
|
+
appendFileSync(join3(dir, "results.jsonl"), `${JSON.stringify(line)}
|
|
20133
|
+
`, { mode: PRIVATE_FILE2 });
|
|
20029
20134
|
}
|
|
20030
20135
|
function recordOutcome(dir, sessionId, follows, entry) {
|
|
20031
20136
|
ensureDirectory(dir);
|
|
@@ -20035,11 +20140,11 @@ function recordOutcome(dir, sessionId, follows, entry) {
|
|
|
20035
20140
|
function writePicture(dir, image) {
|
|
20036
20141
|
const bytes = Buffer.from(image.data, "base64");
|
|
20037
20142
|
const name = createHash2("sha256").update(bytes).digest("hex").slice(0, 16);
|
|
20038
|
-
const pictures =
|
|
20039
|
-
const path =
|
|
20143
|
+
const pictures = join3(dir, "pictures");
|
|
20144
|
+
const path = join3(pictures, `${name}.${PICTURE_EXTENSIONS[image.mimeType]}`);
|
|
20040
20145
|
ensureDirectory(pictures);
|
|
20041
20146
|
try {
|
|
20042
|
-
|
|
20147
|
+
writeFileSync3(path, bytes, { mode: PRIVATE_FILE2, flag: "wx" });
|
|
20043
20148
|
} catch (error) {
|
|
20044
20149
|
if (!hasErrnoCode(error, "EEXIST"))
|
|
20045
20150
|
throw error;
|
|
@@ -20047,17 +20152,17 @@ function writePicture(dir, image) {
|
|
|
20047
20152
|
return { path, bytes: bytes.length };
|
|
20048
20153
|
}
|
|
20049
20154
|
function writeLongOutput(dir, text) {
|
|
20050
|
-
const out =
|
|
20155
|
+
const out = join3(dir, "out");
|
|
20051
20156
|
ensureDirectory(out);
|
|
20052
|
-
const path =
|
|
20053
|
-
|
|
20157
|
+
const path = join3(out, `${new Date().toISOString().replaceAll(":", "-")}-${process.pid}.md`);
|
|
20158
|
+
writeFileSync3(path, text, { mode: PRIVATE_FILE2 });
|
|
20054
20159
|
return path;
|
|
20055
20160
|
}
|
|
20056
20161
|
function readSessionTitle(dir) {
|
|
20057
|
-
return readIfPresent(
|
|
20162
|
+
return readIfPresent(join3(dir, "title")) || undefined;
|
|
20058
20163
|
}
|
|
20059
20164
|
function pruneSessions(root, now) {
|
|
20060
|
-
const stamp =
|
|
20165
|
+
const stamp = join3(root, PRUNED_STAMP);
|
|
20061
20166
|
try {
|
|
20062
20167
|
if (now - statSync2(stamp).mtimeMs < PRUNE_INTERVAL_MS)
|
|
20063
20168
|
return;
|
|
@@ -20065,7 +20170,7 @@ function pruneSessions(root, now) {
|
|
|
20065
20170
|
if (!hasErrnoCode(error, "ENOENT"))
|
|
20066
20171
|
throw error;
|
|
20067
20172
|
}
|
|
20068
|
-
const sessions =
|
|
20173
|
+
const sessions = join3(root, "sessions");
|
|
20069
20174
|
let entries = [];
|
|
20070
20175
|
try {
|
|
20071
20176
|
entries = readdirSync2(sessions, { withFileTypes: true });
|
|
@@ -20076,10 +20181,10 @@ function pruneSessions(root, now) {
|
|
|
20076
20181
|
for (const entry of entries) {
|
|
20077
20182
|
if (!entry.isDirectory())
|
|
20078
20183
|
continue;
|
|
20079
|
-
const dir =
|
|
20184
|
+
const dir = join3(sessions, entry.name);
|
|
20080
20185
|
try {
|
|
20081
20186
|
if (now - statSync2(dir).mtimeMs > IDLE_SESSION_MS && askgateState(dir).kind === "off") {
|
|
20082
|
-
|
|
20187
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
20083
20188
|
}
|
|
20084
20189
|
} catch (error) {
|
|
20085
20190
|
if (!hasErrnoCode(error, "ENOENT"))
|
|
@@ -20088,7 +20193,7 @@ function pruneSessions(root, now) {
|
|
|
20088
20193
|
}
|
|
20089
20194
|
sweepStores(root, now);
|
|
20090
20195
|
ensureDirectory(root);
|
|
20091
|
-
|
|
20196
|
+
writeFileSync3(stamp, "", { mode: PRIVATE_FILE2 });
|
|
20092
20197
|
}
|
|
20093
20198
|
|
|
20094
20199
|
// ../envoy-client/src/envoy-config.ts
|
|
@@ -20101,7 +20206,7 @@ import {
|
|
|
20101
20206
|
openSync as openSync3,
|
|
20102
20207
|
readFileSync as readFileSync4,
|
|
20103
20208
|
readlinkSync,
|
|
20104
|
-
rmSync as
|
|
20209
|
+
rmSync as rmSync4,
|
|
20105
20210
|
statSync as statSync3
|
|
20106
20211
|
} from "fs";
|
|
20107
20212
|
import { homedir as homedir2, tmpdir } from "os";
|
|
@@ -20367,9 +20472,9 @@ function parseFile(filePath, schema, checkout) {
|
|
|
20367
20472
|
}
|
|
20368
20473
|
var FAILURE_KEPT_MS = 60000;
|
|
20369
20474
|
var COMMAND_OUTCOMES = Symbol.for("legion.envoy-config.commands.v1");
|
|
20370
|
-
var
|
|
20371
|
-
var commandOutcomes =
|
|
20372
|
-
|
|
20475
|
+
var processGlobal2 = globalThis;
|
|
20476
|
+
var commandOutcomes = processGlobal2[COMMAND_OUTCOMES] ?? new Map;
|
|
20477
|
+
processGlobal2[COMMAND_OUTCOMES] = commandOutcomes;
|
|
20373
20478
|
function runCommand(command, env, cwd) {
|
|
20374
20479
|
const childEnv = {};
|
|
20375
20480
|
for (const [name, variable] of Object.entries(env)) {
|
|
@@ -20423,10 +20528,10 @@ function runCommand(command, env, cwd) {
|
|
|
20423
20528
|
}
|
|
20424
20529
|
return { value };
|
|
20425
20530
|
} finally {
|
|
20426
|
-
|
|
20531
|
+
rmSync4(directory, { recursive: true, force: true });
|
|
20427
20532
|
}
|
|
20428
20533
|
}
|
|
20429
|
-
function fileValue(value, field, filePath, env) {
|
|
20534
|
+
function fileValue(value, field, filePath, env, commands = "run") {
|
|
20430
20535
|
if (!value.startsWith("!")) {
|
|
20431
20536
|
if (!sendable(value)) {
|
|
20432
20537
|
throw new Error(`${filePath}: ${field} holds whitespace or a control character`);
|
|
@@ -20437,7 +20542,11 @@ function fileValue(value, field, filePath, env) {
|
|
|
20437
20542
|
const cwd = path.dirname(path.resolve(filePath));
|
|
20438
20543
|
const key = JSON.stringify([command, filePath, cwd]);
|
|
20439
20544
|
let outcome = commandOutcomes.get(key);
|
|
20440
|
-
if (
|
|
20545
|
+
if (commands === "kept") {
|
|
20546
|
+
if (outcome === undefined) {
|
|
20547
|
+
throw new Error(`${filePath}: ${field}'s command has not run in this process yet, and this read does not run it`);
|
|
20548
|
+
}
|
|
20549
|
+
} else if (outcome === undefined || "failure" in outcome && !outcome.timedOut && Date.now() - outcome.at >= FAILURE_KEPT_MS) {
|
|
20441
20550
|
outcome = { ...runCommand(command, env, cwd), at: Date.now() };
|
|
20442
20551
|
commandOutcomes.set(key, outcome);
|
|
20443
20552
|
}
|
|
@@ -20547,7 +20656,7 @@ function bearerSource(env, section, written) {
|
|
|
20547
20656
|
const bearer = written();
|
|
20548
20657
|
return bearer === undefined ? { kind: "none" } : { kind: "file", ...bearer };
|
|
20549
20658
|
}
|
|
20550
|
-
function fileBearer(bearer, url, section, env) {
|
|
20659
|
+
function fileBearer(bearer, url, section, env, commands = "run") {
|
|
20551
20660
|
const named = httpUrl2(url)?.origin;
|
|
20552
20661
|
const paired = httpUrl2(bearer.server)?.origin;
|
|
20553
20662
|
if (named !== paired) {
|
|
@@ -20559,7 +20668,7 @@ function fileBearer(bearer, url, section, env) {
|
|
|
20559
20668
|
if (bearer.token === "") {
|
|
20560
20669
|
throw new Error(`${bearer.field} in ${bearer.path} must be a non-empty bearer token`);
|
|
20561
20670
|
}
|
|
20562
|
-
return fileValue(bearer.token, bearer.field, bearer.path, env);
|
|
20671
|
+
return fileValue(bearer.token, bearer.field, bearer.path, env, commands);
|
|
20563
20672
|
}
|
|
20564
20673
|
|
|
20565
20674
|
// ../envoy-client/src/dispatch-config.ts
|
|
@@ -20594,7 +20703,30 @@ function writtenToken(config, checkout) {
|
|
|
20594
20703
|
checkout: false
|
|
20595
20704
|
};
|
|
20596
20705
|
}
|
|
20597
|
-
function dispatchSettings(env, config) {
|
|
20706
|
+
function dispatchSettings(env, config, options = {}) {
|
|
20707
|
+
const target = dispatchTarget(env, config);
|
|
20708
|
+
if (!target.enabled)
|
|
20709
|
+
return target;
|
|
20710
|
+
const { url, source } = target;
|
|
20711
|
+
let token;
|
|
20712
|
+
try {
|
|
20713
|
+
token = source.kind === "variable" ? source.token : fileBearer(source, url, "DISPATCH", env, options.commands);
|
|
20714
|
+
} catch (error) {
|
|
20715
|
+
return offWithError(messageFor(error));
|
|
20716
|
+
}
|
|
20717
|
+
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
20718
|
+
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
20719
|
+
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
20720
|
+
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
20721
|
+
}
|
|
20722
|
+
return {
|
|
20723
|
+
enabled: true,
|
|
20724
|
+
url,
|
|
20725
|
+
token,
|
|
20726
|
+
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
20727
|
+
};
|
|
20728
|
+
}
|
|
20729
|
+
function dispatchTarget(env, config) {
|
|
20598
20730
|
if (config.kind === "failed")
|
|
20599
20731
|
return offWithError(config.error);
|
|
20600
20732
|
const explicitUrl = nonEmpty(env.DISPATCH_URL);
|
|
@@ -20617,27 +20749,17 @@ function dispatchSettings(env, config) {
|
|
|
20617
20749
|
if (explicitUrl !== undefined && httpUrl2(explicitUrl) === undefined) {
|
|
20618
20750
|
return offWithError("DISPATCH_URL must be an http or https URL");
|
|
20619
20751
|
}
|
|
20752
|
+
let source;
|
|
20620
20753
|
try {
|
|
20621
|
-
|
|
20622
|
-
if (source.kind === "none") {
|
|
20623
|
-
return offWithError(explicitUrl === undefined ? `${config.path}: dispatch is enabled and no token is: set dispatch.token, or DISPATCH_TOKEN or DISPATCH_TOKEN_FILE` : `DISPATCH_URL is set and no token is: set DISPATCH_TOKEN or DISPATCH_TOKEN_FILE, or dispatch.token in ${config.path}`);
|
|
20624
|
-
}
|
|
20625
|
-
const url = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
20626
|
-
const token = source.kind === "variable" ? source.token : fileBearer(source, url, "DISPATCH", env);
|
|
20627
|
-
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
20628
|
-
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
20629
|
-
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
20630
|
-
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
20631
|
-
}
|
|
20632
|
-
return {
|
|
20633
|
-
enabled: true,
|
|
20634
|
-
url,
|
|
20635
|
-
token,
|
|
20636
|
-
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
20637
|
-
};
|
|
20754
|
+
source = checkout !== null && checkoutServer !== undefined ? checkoutPair(checkout, checkoutServer) : bearerSource(env, "DISPATCH", () => writtenToken(config, checkout));
|
|
20638
20755
|
} catch (error) {
|
|
20639
20756
|
return offWithError(messageFor(error));
|
|
20640
20757
|
}
|
|
20758
|
+
if (source.kind === "none") {
|
|
20759
|
+
return offWithError(explicitUrl === undefined ? `${config.path}: dispatch is enabled and no token is: set dispatch.token, or DISPATCH_TOKEN or DISPATCH_TOKEN_FILE` : `DISPATCH_URL is set and no token is: set DISPATCH_TOKEN or DISPATCH_TOKEN_FILE, or dispatch.token in ${config.path}`);
|
|
20760
|
+
}
|
|
20761
|
+
const url = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
20762
|
+
return { enabled: true, url, source };
|
|
20641
20763
|
}
|
|
20642
20764
|
|
|
20643
20765
|
// ../envoy-client/src/envoy-settings.ts
|
|
@@ -20774,7 +20896,7 @@ function sessionDispatchSettings(file, env, options) {
|
|
|
20774
20896
|
return { dispatch: offWithError(file.error), warnings: [], missing: null };
|
|
20775
20897
|
const config = readEnvoyConfig(file.path, env, options);
|
|
20776
20898
|
return {
|
|
20777
|
-
dispatch: dispatchSettings(env, config),
|
|
20899
|
+
dispatch: dispatchSettings(env, config, { commands: options.commands }),
|
|
20778
20900
|
warnings: config.kind === "read" ? config.warnings : [],
|
|
20779
20901
|
missing: config.kind === "read" ? config.missing : null
|
|
20780
20902
|
};
|
package/dist/src/server.js
CHANGED
|
@@ -14206,7 +14206,7 @@ var dispatchToolSpecs = [
|
|
|
14206
14206
|
{
|
|
14207
14207
|
name: "dispatch_doc_read",
|
|
14208
14208
|
example: { issue: "DSP-1" },
|
|
14209
|
-
description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
|
|
14209
|
+
description: "Read a live document or a named document version, or the text of an uploaded file at its latest or named version. " + "Do not use it for issue status, asks, or events; " + "use dispatch read instead. Supply ref, issue, or project plus artifact; issue plus an omitted artifact reads the primary document. " + "A live read returns its document token for an optional dispatch doc-edit precondition. With blocks it lists the live document's blocks instead of its markdown, in document order: each block's id, type, token, anchored comments and asks, and opening words, and under a table each row by the index delete_row takes and each cell by its row and the column delete_column takes; block ids, block tokens and those indexes are what dispatch doc-edit takes. " + "After the markdown it prints every open thread anchored on the document in full: an open ask with its id and question and each reply's author, time and text, and a comment thread with its root's id, author, time, quote and text and each reply's author, time and text, in order; a resolved thread, and an ask that is answered or resolved, is not printed. " + "A picture (an uploaded PNG, JPEG, GIF or WebP of at most 3,750,000 bytes, 5 MB once base64-encoded) comes back as an image you see, with its name, type, size and version, every time you ask, including one a read already showed this session; " + "any other file that is not UTF-8 text is described, with the route that serves its bytes. " + OWNER_REFERENCE,
|
|
14210
14210
|
arguments: (z) => ({
|
|
14211
14211
|
issue: z.string().describe(ISSUE_REFERENCE).optional(),
|
|
14212
14212
|
project: z.string().describe("Project key owning the document.").optional(),
|
|
@@ -14366,6 +14366,13 @@ var dispatchToolSpecs = [
|
|
|
14366
14366
|
}),
|
|
14367
14367
|
strict: true
|
|
14368
14368
|
},
|
|
14369
|
+
{
|
|
14370
|
+
name: "dispatch_answers",
|
|
14371
|
+
example: {},
|
|
14372
|
+
description: "List every answer and comment a person wrote to this session in the last 72 hours, newest first, each in full: " + "answers to its asks, replies on its asks and in its comment threads, comments on its documents and on issues routed to it, " + "mentions of it, messages to it and replies to its messages. Each item names its time, its writer, what it is, its " + "dispatch:// reference and its issue or document, an answer's question and choices, and the words, never cut. " + "Run it when the list of people's answers on a model request says it left older items out. " + "It reads this session's id from CLAUDE_CODE_SESSION_ID under Claude Code and DISPATCH_SESSION_ID elsewhere, so a subagent reads its parent's.",
|
|
14373
|
+
arguments: () => ({}),
|
|
14374
|
+
strict: true
|
|
14375
|
+
},
|
|
14369
14376
|
{
|
|
14370
14377
|
name: "dispatch_whoami",
|
|
14371
14378
|
example: {},
|
|
@@ -14834,7 +14841,7 @@ function runCommand(command, env, cwd) {
|
|
|
14834
14841
|
rmSync(directory, { recursive: true, force: true });
|
|
14835
14842
|
}
|
|
14836
14843
|
}
|
|
14837
|
-
function fileValue(value, field, filePath, env) {
|
|
14844
|
+
function fileValue(value, field, filePath, env, commands = "run") {
|
|
14838
14845
|
if (!value.startsWith("!")) {
|
|
14839
14846
|
if (!sendable(value)) {
|
|
14840
14847
|
throw new Error(`${filePath}: ${field} holds whitespace or a control character`);
|
|
@@ -14845,7 +14852,11 @@ function fileValue(value, field, filePath, env) {
|
|
|
14845
14852
|
const cwd = path.dirname(path.resolve(filePath));
|
|
14846
14853
|
const key = JSON.stringify([command, filePath, cwd]);
|
|
14847
14854
|
let outcome = commandOutcomes.get(key);
|
|
14848
|
-
if (
|
|
14855
|
+
if (commands === "kept") {
|
|
14856
|
+
if (outcome === undefined) {
|
|
14857
|
+
throw new Error(`${filePath}: ${field}'s command has not run in this process yet, and this read does not run it`);
|
|
14858
|
+
}
|
|
14859
|
+
} else if (outcome === undefined || "failure" in outcome && !outcome.timedOut && Date.now() - outcome.at >= FAILURE_KEPT_MS) {
|
|
14849
14860
|
outcome = { ...runCommand(command, env, cwd), at: Date.now() };
|
|
14850
14861
|
commandOutcomes.set(key, outcome);
|
|
14851
14862
|
}
|
|
@@ -14955,7 +14966,7 @@ function bearerSource(env, section, written) {
|
|
|
14955
14966
|
const bearer = written();
|
|
14956
14967
|
return bearer === undefined ? { kind: "none" } : { kind: "file", ...bearer };
|
|
14957
14968
|
}
|
|
14958
|
-
function fileBearer(bearer, url, section, env) {
|
|
14969
|
+
function fileBearer(bearer, url, section, env, commands = "run") {
|
|
14959
14970
|
const named = httpUrl2(url)?.origin;
|
|
14960
14971
|
const paired = httpUrl2(bearer.server)?.origin;
|
|
14961
14972
|
if (named !== paired) {
|
|
@@ -14967,7 +14978,7 @@ function fileBearer(bearer, url, section, env) {
|
|
|
14967
14978
|
if (bearer.token === "") {
|
|
14968
14979
|
throw new Error(`${bearer.field} in ${bearer.path} must be a non-empty bearer token`);
|
|
14969
14980
|
}
|
|
14970
|
-
return fileValue(bearer.token, bearer.field, bearer.path, env);
|
|
14981
|
+
return fileValue(bearer.token, bearer.field, bearer.path, env, commands);
|
|
14971
14982
|
}
|
|
14972
14983
|
|
|
14973
14984
|
// ../envoy-client/src/dispatch-config.ts
|
|
@@ -15002,7 +15013,30 @@ function writtenToken(config, checkout) {
|
|
|
15002
15013
|
checkout: false
|
|
15003
15014
|
};
|
|
15004
15015
|
}
|
|
15005
|
-
function dispatchSettings(env, config) {
|
|
15016
|
+
function dispatchSettings(env, config, options = {}) {
|
|
15017
|
+
const target = dispatchTarget(env, config);
|
|
15018
|
+
if (!target.enabled)
|
|
15019
|
+
return target;
|
|
15020
|
+
const { url, source } = target;
|
|
15021
|
+
let token;
|
|
15022
|
+
try {
|
|
15023
|
+
token = source.kind === "variable" ? source.token : fileBearer(source, url, "DISPATCH", env, options.commands);
|
|
15024
|
+
} catch (error) {
|
|
15025
|
+
return offWithError(messageFor(error));
|
|
15026
|
+
}
|
|
15027
|
+
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
15028
|
+
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
15029
|
+
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
15030
|
+
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
15031
|
+
}
|
|
15032
|
+
return {
|
|
15033
|
+
enabled: true,
|
|
15034
|
+
url,
|
|
15035
|
+
token,
|
|
15036
|
+
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
15037
|
+
};
|
|
15038
|
+
}
|
|
15039
|
+
function dispatchTarget(env, config) {
|
|
15006
15040
|
if (config.kind === "failed")
|
|
15007
15041
|
return offWithError(config.error);
|
|
15008
15042
|
const explicitUrl = nonEmpty(env.DISPATCH_URL);
|
|
@@ -15025,27 +15059,17 @@ function dispatchSettings(env, config) {
|
|
|
15025
15059
|
if (explicitUrl !== undefined && httpUrl2(explicitUrl) === undefined) {
|
|
15026
15060
|
return offWithError("DISPATCH_URL must be an http or https URL");
|
|
15027
15061
|
}
|
|
15062
|
+
let source;
|
|
15028
15063
|
try {
|
|
15029
|
-
|
|
15030
|
-
if (source.kind === "none") {
|
|
15031
|
-
return offWithError(explicitUrl === undefined ? `${config.path}: dispatch is enabled and no token is: set dispatch.token, or DISPATCH_TOKEN or DISPATCH_TOKEN_FILE` : `DISPATCH_URL is set and no token is: set DISPATCH_TOKEN or DISPATCH_TOKEN_FILE, or dispatch.token in ${config.path}`);
|
|
15032
|
-
}
|
|
15033
|
-
const url = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
15034
|
-
const token = source.kind === "variable" ? source.token : fileBearer(source, url, "DISPATCH", env);
|
|
15035
|
-
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
15036
|
-
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
15037
|
-
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
15038
|
-
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
15039
|
-
}
|
|
15040
|
-
return {
|
|
15041
|
-
enabled: true,
|
|
15042
|
-
url,
|
|
15043
|
-
token,
|
|
15044
|
-
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
15045
|
-
};
|
|
15064
|
+
source = checkout !== null && checkoutServer !== undefined ? checkoutPair(checkout, checkoutServer) : bearerSource(env, "DISPATCH", () => writtenToken(config, checkout));
|
|
15046
15065
|
} catch (error) {
|
|
15047
15066
|
return offWithError(messageFor(error));
|
|
15048
15067
|
}
|
|
15068
|
+
if (source.kind === "none") {
|
|
15069
|
+
return offWithError(explicitUrl === undefined ? `${config.path}: dispatch is enabled and no token is: set dispatch.token, or DISPATCH_TOKEN or DISPATCH_TOKEN_FILE` : `DISPATCH_URL is set and no token is: set DISPATCH_TOKEN or DISPATCH_TOKEN_FILE, or dispatch.token in ${config.path}`);
|
|
15070
|
+
}
|
|
15071
|
+
const url = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
15072
|
+
return { enabled: true, url, source };
|
|
15049
15073
|
}
|
|
15050
15074
|
|
|
15051
15075
|
// ../envoy-client/src/envoy-settings.ts
|
|
@@ -15619,7 +15643,7 @@ function sourceSession(sessionID) {
|
|
|
15619
15643
|
// src/server.ts
|
|
15620
15644
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
15621
15645
|
// package.json
|
|
15622
|
-
var version2 = "12.0
|
|
15646
|
+
var version2 = "12.2.0";
|
|
15623
15647
|
|
|
15624
15648
|
// src/log.ts
|
|
15625
15649
|
import { appendFile, mkdir } from "fs/promises";
|
package/package.json
CHANGED
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -45,12 +45,12 @@ Which machines, files or people, the numbers, then "A happens to X, or B to Y?"
|
|
|
45
45
|
|
|
46
46
|
Outside Legion, claim your issue (`dispatch claim`) and move it and your children: `in_progress` at implementation, `testing` on a production-like surface, `needs_review` while its PR waits, `done` with `--reason` once driven in production. An issue with the `legion` label, or under one: never write its status or route it, and claim it only as a Legion role of its tree, even when its claim reads `· not running`, as a suspended architect's does: taking it removes the tree from the workflow.
|
|
47
47
|
|
|
48
|
-
A claim, or `dispatch issue-update --route session:<your id>` (`--route ''` on release), brings a person's answers, comments and messages; for agents' activity, `envoy_subscribe notifications.dispatch.issue.<KEY
|
|
48
|
+
A claim, or `dispatch issue-update --route session:<your id>` (`--route ''` on release), brings a person's answers, comments and messages; for agents' activity, `envoy_subscribe notifications.dispatch.issue.<KEY>.>`, which shows no event Dispatch marks quiet, like an unnamed autosave.
|
|
49
49
|
|
|
50
50
|
## Close what you opened
|
|
51
51
|
|
|
52
|
-
- An ask you opened is yours until answered or resolved.
|
|
53
|
-
-
|
|
52
|
+
- An ask you opened is yours until answered or resolved. `dispatch resolve-ask` it that turn once anything makes it needless: another answer, a later write, work dropped.
|
|
53
|
+
- Retract an ask a new one replaces.
|
|
54
54
|
|
|
55
55
|
## Structure over stream
|
|
56
56
|
|
|
@@ -33,7 +33,7 @@ For each spec, ask, comment and message:
|
|
|
33
33
|
- Write about the current design, never the person or how people reached it. Never quote people or upload their words.
|
|
34
34
|
- Link each source where the person wrote it. With no link, state the design.
|
|
35
35
|
- In a spec, cite code by file and symbol, never a line number: it goes stale at the next edit.
|
|
36
|
-
- A sentence that cites a gate passing (a test, review, check or proof) also says
|
|
36
|
+
- A sentence that cites a gate passing (a test, review, check or proof) also says what the pass does not show: "The tests pass; they catch a wrong verdict, not a proof of a contradiction."
|
|
37
37
|
- Name your session only if a reader must reach it.
|
|
38
38
|
- Keep only text that helps a reader understand, decide, build, check.
|
|
39
39
|
- Explain each name or term a new reader may not know. Say in a clause what each issue you name does for this reader.
|
|
@@ -29,6 +29,7 @@ and 7 (a conflict, the hand-over, the close) is in
|
|
|
29
29
|
| `pr-blocked` | Its `reason` names the pull request and the `max_fix_attempts` it reached, or reads `Issue reached review_round_cap=<N>.` (the end of this row). The count is of heads pushed onto a red verdict that changed something outside `.legion/<issue>/` — handoff-only pushes (`.legion/<issue>/` paths only) never count, a push by the review App (a planner's, tester's, reviewer's or architect's) never counts, and the head after a red the tester's red tests earned (a review-App push that changed a path outside `.legion/<issue>/`, however many handoff-only pushes follow it) does not count either — so after the tester's handoff-only push onto the implementer's red, the implementer's next push does count; a push the daemon cannot classify (a listener without `changed_paths`, a list the listener stopped at 100 paths or 32,768 runes of text, a push listing no commits) does. Published once per exhausted count, not on every later red verdict for that count. Read the failed CI evidence and recovery attempts. The notice moves nothing, and no later CI red within the attempt that ran the count out (the counted head, and every head pushed on top of it until a push lands on a head CI judged green or on a settled head whose base branch requires no check and no workflow, the tester's red tests and handoff onto the held red included) sends the work back from `testing`, `reviewing`, `accepting`, `retro` or `merging` either: the issue stays in its phase until you move it, or a worker's own decision does (a tester's `fail`, a reviewer's request for changes). A head pushed onto green CI, or onto a settled head whose base branch requires no check and no workflow, leaves that attempt, so its red still comes as a `checks-red`. In `implementing`, give the implementer the failing checks (`envoy_publish` to its role topic). In any later phase a worker holds, tell that worker (`envoy_publish` to its role topic) to move the issue back to `implementing` with `request_backward_move`, naming the failing checks, and the daemon starts the implementer; or file a corrective child. In `accepting`, where no worker holds a phase, the move is yours: `request_backward_move` with `issue`, naming the failing checks. In `awaiting_merge`, where no worker holds a phase and a backward move is refused, open a `dispatch ask` naming the failing checks for the human who merges. Do not treat the blocked PR as final. A `review_round_cap` reason is the implementer's round count instead: every move back to an earlier phase counts one, and the notice comes again with each round past the cap. It moves nothing either, since the round that reached the cap has already started its worker. Tell the rounds apart by what announced each: a tester's fail by its `phase-finished` with `verdict: "fail"`; a request for changes by the reviewer's `phase-finished` over a `CHANGES_REQUESTED` review; the daemon's own move by a `checks-red` notice, whose reason names a conflict (`the head conflicts with <base>`), the required checks that failed (`CI is red at <head>`), or the merge queue's failed merge and its failing run (`the merge queue took pull request #<n> out at READY's head …`); a worker's `request_backward_move` by a `phase-finished` from the role that asked, whose reason reaches only the task of the worker the move started, so ask that worker; your own send-back from `accepting` by the reason you gave. Judge the cap by the rounds that are not conflicts. Conflict returns need nothing from you beyond the next round. Tester fails, requests for changes and CI-red returns (a `checks-red` notice is raised for CI only when a required check fails on code the branch pushed, or when the merge queue fails the merge) are the rounds the cap is for. Each `review_round_cap` notice is a decision you make in the turn it wakes, never one left for the next round to make: read what every counted round found, choose one of the three below, act on it in the same turn, and post it as a `dispatch comment` on the issue naming the rounds you read and the choice. **Narrow**: the change itself is sound but each round finds more around it; tell the implementer and the reviewer (`envoy_publish` to each one's role topic) which acceptance lines the next round answers; a correctness finding is still fixed in this pull request, and so is a wording nit or any clear fix in a file it touches, so the `Fast-follow:` line carries only cleanup in files this pull request does not touch. **Split**: part of the work cannot settle in this pull request. On a child, file that part as a child of this issue's parent, never of this one (this issue closes at its own merge, and the split part usually builds on this pull request's unmerged code): `dispatch issue --project <project> --parent <parent> --title '<outcome>' --blocked-by <this issue> --spec-file -`. Release it now with `release_children`: Legion holds it on that `--blocked-by` link until this issue closes, and starts it then. On the root, narrow it to what settles here and open a `dispatch ask` for the rest (`skill://legion-worker/references/findings.md`): a person decides whether that becomes an issue. Tell the implementer and the reviewer that this pull request no longer carries it. **Accept the rounds**: each round found less than the one before it, and the last found no defect in the change itself (a stale line, a comment that states an old default); let the loop run and say why in the comment. A later notice is a new decision: accept again only when its round found less than the round you last accepted, and otherwise narrow or split. A finding that changes what the spec accepts is a decision block in the root spec ("After approval" in `skill://legion-architect/references/design-gate.md`), and `dispatch ask` is only for a decision that needs a human's authority; neither replaces the choice. Judge a worker's backward move by the reason that worker gives. A move you ordered for a conflict found before `awaiting_merge` ("A conflicting head" in `skill://legion-architect/references/acceptance.md`) is a conflict return. The merger's move back to `reviewing` for a READY refusal (`READY_UNREVIEWED_CHANGE`, or `READY_NOT_APPROVED` when its task names no approved head), and the reviewer's no-fix request for changes that follows it so the tester runs the head, are not the work going in circles either. A move that sends back code found wrong counts with the tester fails. |
|
|
30
30
|
| `pr-merged` | The pull request merged and the issue did not close; its `reason` says why. Merged before the issue reached `awaiting_merge`: the workflow runs on, and the merge closes the issue the moment it gets there, your acceptance and retro first. Merged while the rest of the tree still runs: the reason names the pull request and every issue the close waits for, and the root waits in `awaiting_merge`, slot and workers kept, until the last of them is done or parked ("The close" in `skill://legion-architect/references/acceptance.md`). Either way you start nothing and no further notice follows. |
|
|
31
31
|
| `pr-closed-unmerged` | Decide from current scope whether the work is reopened, started over (`park_child` then `rerun_child`, for a child), or ended with a reason. Delegate the repository action to the responsible phase worker and keep ownership. |
|
|
32
|
+
| `pull-request-missing` | Its `reason` names the issue, the repository, the branch and the handoff commit: the issue's implementer completed at that commit and GitHub lists no open pull request on the branch, so the issue stays in `implementing`. Tell the implementer (`envoy_publish` to its role topic) to open the pull request from that branch; the daemon records it and moves the issue to `testing` once it is open. When the work should not go on, decide as the `pr-closed-unmerged` row says. It comes once per handoff commit. |
|
|
32
33
|
| `merge-refused` | GitHub refused the daemon's submission of READY's merge in a way a retry would meet again, so it submits that merge no further: ask a person with `dispatch ask` to fix the repository setting or App permission and merge at READY's head (`skill://legion-architect/references/merge-notices.md#merge-refused`). |
|
|
33
34
|
| `merge-stalled` | GitHub shows the pull request closed, or at another head, while the record still awaits READY's head, and that change's webhook never reached the daemon: read the pull request on GitHub and act on what it shows (`skill://legion-architect/references/merge-notices.md#merge-stalled`). |
|
|
34
35
|
| `merge-removed` | A person, or GitHub on its own, took the pull request out of the merge queue or turned its auto-merge off at READY's head after the daemon submitted the merge: read the pull request on GitHub and act on the removal's reason (`skill://legion-architect/references/merge-notices.md#merge-removed`). |
|
|
@@ -19,7 +19,7 @@ You work one phase of one issue in a jj workspace every role of the issue shares
|
|
|
19
19
|
|
|
20
20
|
## Stop rules
|
|
21
21
|
|
|
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.
|
|
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_NO_PULL_REQUEST` (the implementer's completion found no open pull request on `legion/<KEY>`, and none recorded merged): open it and complete again at the same commit; when the answer names a closed pull request, the architect decides the phase: tell it, and open or reopen nothing until it answers. `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`, `HANDOFF_NO_PULL_REQUEST` 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
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.
|
|
24
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.
|
|
25
25
|
- **Never start another Legion role**: the daemon starts every phase worker.
|