@deepseek-ai/dsh-experimental-forge-session 0.0.0-stage → 0.2.2-alpha.2

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/lib/index.js ADDED
@@ -0,0 +1,844 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { changeRequestKey } from "@deepseek-ai/dsh-experimental-forge";
4
+ import { SessionSeq, appendPluginRecord, pluginRecordOf } from "@deepseek-ai/dsh-session";
5
+ import { MAX_TIMER_DELAY_MS, deadline } from "@deepseek-ai/dsh-timeout";
6
+ import parse from "shell-quote/parse.js";
7
+ import quote from "shell-quote/quote.js";
8
+ import { z as z$1 } from "zod";
9
+ import { assertNever } from "@deepseek-ai/dsh-util-values";
10
+ //#region lib/types/references.js
11
+ /** Literal GitHub CLI references, with shell quoting and command separators preserved. */
12
+ const VALUE_OPTIONS = new Set([
13
+ "--json",
14
+ "--jq",
15
+ "-q",
16
+ "--template",
17
+ "-t",
18
+ "--title",
19
+ "--body",
20
+ "-b",
21
+ "--body-file",
22
+ "-F",
23
+ "--base",
24
+ "-B",
25
+ "--add-assignee",
26
+ "--add-label",
27
+ "--add-project",
28
+ "--add-reviewer",
29
+ "--milestone",
30
+ "-m",
31
+ "--remove-assignee",
32
+ "--remove-label",
33
+ "--remove-project",
34
+ "--remove-reviewer",
35
+ "--branch"
36
+ ]);
37
+ const BOOLEAN_OPTIONS = new Set([
38
+ "--comments",
39
+ "-c",
40
+ "--web",
41
+ "-w",
42
+ "--detach",
43
+ "-d",
44
+ "--force",
45
+ "-f",
46
+ "--recurse-submodules",
47
+ "--remove-milestone"
48
+ ]);
49
+ const SEPARATORS = new Set([
50
+ "&&",
51
+ "||",
52
+ ";",
53
+ "|",
54
+ "|&",
55
+ "&"
56
+ ]);
57
+ const REPOSITORY = /^(?:([a-z\d.-]+(?::\d+)?)\/)?([a-z\d_-]+)\/([a-z\d_.-]+)$/i;
58
+ /**
59
+ * Read explicit URL selectors or numeric selectors with `--repo`/`-R`.
60
+ * Shell expansion, multiline scripts, substitutions, and unsupported options
61
+ * are excluded. Unqualified repositories can be resolved only for a single
62
+ * simple command, optionally piped to an output filter; compound commands
63
+ * may change the CLI environment. PowerShell callers may use only `url`.
64
+ * @param command - logged shell command text.
65
+ * @returns references in command order; no process or environment is read.
66
+ */
67
+ function ghReferences(command) {
68
+ if (/[\r\n`]|\$\(/.test(command) || !/^(?:[^'"\\]|\\.|'(?:[^']*)'|"(?:[^"\\]|\\.)*")*$/.test(command)) return [];
69
+ let tokens;
70
+ try {
71
+ tokens = parse(command, () => {
72
+ throw new Error("dynamic shell word");
73
+ });
74
+ } catch (error) {
75
+ return [];
76
+ }
77
+ const references = [];
78
+ const compound = tokens.some((token, index) => {
79
+ const next = tokens[index + 1];
80
+ return typeof token !== "string" && "op" in token && ([
81
+ "&&",
82
+ "||",
83
+ ";",
84
+ "&"
85
+ ].includes(token.op) || ["|", "|&"].includes(token.op) && (typeof next !== "string" || ![
86
+ "head",
87
+ "tail",
88
+ "cat"
89
+ ].includes(next)));
90
+ });
91
+ let words = [];
92
+ let first = true;
93
+ let unsupported = false;
94
+ const flush = () => {
95
+ const reference = unsupported ? void 0 : referenceOf(words, first && !compound);
96
+ if (reference !== void 0) references.push(reference);
97
+ words = [];
98
+ first = false;
99
+ unsupported = false;
100
+ };
101
+ for (let index = 0; index < tokens.length; index++) {
102
+ const token = tokens[index];
103
+ if (typeof token === "string") words.push(token);
104
+ else if (token !== void 0 && "op" in token && SEPARATORS.has(token.op)) flush();
105
+ else if (token !== void 0 && "op" in token && token.op === ">&" && words.at(-1) === "2" && tokens[index + 1] === "1") {
106
+ words.pop();
107
+ index++;
108
+ } else unsupported = true;
109
+ }
110
+ flush();
111
+ return references;
112
+ }
113
+ function referenceOf(words, allowLookup) {
114
+ const environment = [];
115
+ let executable = words[0] === "env" ? 1 : 0;
116
+ for (const word of words.slice(executable)) {
117
+ if (!/^[A-Za-z_][A-Za-z\d_]*=/.test(word)) break;
118
+ environment.push(word);
119
+ executable++;
120
+ }
121
+ if (words[executable] !== "gh") return void 0;
122
+ let repository;
123
+ let take = null;
124
+ const positional = [];
125
+ for (const arg of words.slice(executable + 1)) {
126
+ if (take !== null) {
127
+ if (take === "repo") repository = arg;
128
+ take = null;
129
+ continue;
130
+ }
131
+ if (arg === "--repo" || arg === "-R") take = "repo";
132
+ else if (arg.startsWith("--repo=")) repository = arg.slice(7);
133
+ else if (arg.startsWith("-R") && arg.length > 2) repository = arg.slice(2);
134
+ else {
135
+ const option = arg.replace(/=.*/, "");
136
+ if (VALUE_OPTIONS.has(option)) {
137
+ if (!arg.includes("=")) take = "value";
138
+ } else if (!BOOLEAN_OPTIONS.has(option)) {
139
+ if (arg.startsWith("-")) return void 0;
140
+ positional.push(arg);
141
+ }
142
+ }
143
+ }
144
+ if (take !== null) return void 0;
145
+ const [resource, operation, selector] = positional;
146
+ if (resource !== "pr" || ![
147
+ "view",
148
+ "edit",
149
+ "checkout"
150
+ ].includes(operation ?? "") || selector === void 0 || positional.length !== 3) return void 0;
151
+ const url = /^(https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/[1-9]\d*)(?:[/?#].*)?$/.exec(selector)?.[1];
152
+ if (url !== void 0) return {
153
+ url,
154
+ lookup: null
155
+ };
156
+ if (!/^[1-9]\d*$/.test(selector) || !Number.isSafeInteger(Number(selector)) || repository === void 0) return void 0;
157
+ const match = REPOSITORY.exec(repository.replace(/^https?:\/\//, "").replace(/\.git$/, ""));
158
+ if (match === null || match[3] === "." || match[3] === "..") return void 0;
159
+ const [, repoHost, owner, name] = match;
160
+ const host = repoHost ?? environment.findLast((entry) => entry.startsWith("GH_HOST="))?.slice(8);
161
+ if (host !== void 0 && /^[a-z\d.-]+(?::\d+)?$/i.test(host)) return {
162
+ url: `https://${host}/${owner}/${name}/pull/${selector}`,
163
+ lookup: null
164
+ };
165
+ return {
166
+ url: null,
167
+ lookup: allowLookup ? quote([
168
+ "env",
169
+ ...environment,
170
+ "gh",
171
+ "pr",
172
+ "view",
173
+ selector,
174
+ "--repo",
175
+ repository,
176
+ "--json",
177
+ "url"
178
+ ]) : null
179
+ };
180
+ }
181
+ //#endregion
182
+ //#region lib/types/commands.js
183
+ /** Recognition of shell commands and output that bind change requests to a Session. */
184
+ const CREATE_COMMAND = /\bgh\s+pr\s+(?:create|new)\b/;
185
+ const REFERENCE_COMMAND = /\bgh\s+pr\s+(?:view|edit|checkout)\b/;
186
+ const OWN_URL_LINE = /^(?:url:\s+)?(https?:\/\/[^\s"'<>]+?\/pull\/\d+)\/?$/;
187
+ const OWN_URL_MEMBER = /"url"\s*:\s*"(https?:\/\/[^\s"'<>]+?\/pull\/\d+)\/?"/g;
188
+ const PUSH_COMMAND = /\bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?))*\s+push\b/;
189
+ const CHANGE_REQUEST_URL = /\bhttps?:\/\/[^\s"'<>]+?\/pull\/\d+(?![\w-])/g;
190
+ const PUSH_TARGET_LINE = /^To (\S+)$/;
191
+ const PUSH_REF_LINE = /^ [ +*=] (\[[^\]]+\]|\S+) +\S+ -> (\S+)(?: \(.*\))?$/;
192
+ /**
193
+ * Read the command string of a `bash` or `pwsh` tool call.
194
+ * @param argumentsJson - raw tool-call arguments as the model produced them.
195
+ * @returns the command, or undefined when the arguments are not JSON or carry no string `command`.
196
+ */
197
+ function commandOf(argumentsJson) {
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(argumentsJson);
201
+ } catch (error) {
202
+ return;
203
+ }
204
+ if (typeof parsed !== "object" || parsed === null) return void 0;
205
+ const command = parsed.command;
206
+ return typeof command === "string" ? command : void 0;
207
+ }
208
+ /**
209
+ * Classify the binding evidence a command and its output can carry.
210
+ * `gh pr create` (or its alias `gh pr new`) prints the change request URL;
211
+ * `gh pr view`, `gh pr edit`, and `gh pr checkout` act on one change request,
212
+ * which a URL or a number with an explicit repository and host can name;
213
+ * their output can also print its URL. `git push`,
214
+ * with or without leading `-C`, `-c`, and long options, prints the updated
215
+ * refs. When a command does both, the URLs a result prints bind as created.
216
+ * The match is textual, so quoted mentions also classify; the result parsers
217
+ * then find no evidence in their output.
218
+ * @param command - shell command text.
219
+ * @returns the evidence, or undefined when the command has none of these forms.
220
+ */
221
+ function commandEvidence(command) {
222
+ const create = CREATE_COMMAND.test(command);
223
+ const references = ghReferences(command);
224
+ const reference = REFERENCE_COMMAND.test(command) || references.length > 0;
225
+ const pushes = PUSH_COMMAND.test(command);
226
+ if (!create && !reference && !pushes) return void 0;
227
+ const named = [...new Set(references.flatMap((reference) => reference.url === null ? [] : [reference.url]))];
228
+ return {
229
+ urls: create ? "created" : reference ? "referenced" : null,
230
+ pushes,
231
+ named
232
+ };
233
+ }
234
+ /**
235
+ * Find GitHub pull request URLs in command output.
236
+ * @param text - rendered shell result.
237
+ * @returns distinct URLs in order of first appearance.
238
+ */
239
+ function changeRequestUrls(text) {
240
+ return [...new Set(text.match(CHANGE_REQUEST_URL))];
241
+ }
242
+ /**
243
+ * Find the URLs that `gh pr view`, `gh pr edit`, or `gh pr checkout` output
244
+ * prints for the change request it acts on: gh's `url:` field or a URL alone
245
+ * on a line before the `--` line that starts a description, and JSON `url`
246
+ * members. Links in a description or in other fields name other pages.
247
+ * @param text - rendered shell result.
248
+ * @returns distinct URLs in order of first appearance.
249
+ */
250
+ function ownChangeRequestUrls(text) {
251
+ const urls = [];
252
+ for (const line of text.split(/\r?\n/)) {
253
+ if (line === "--") break;
254
+ const [, own] = OWN_URL_LINE.exec(line.trim()) ?? [];
255
+ if (own !== void 0) urls.push(own);
256
+ }
257
+ for (const [, member = ""] of text.matchAll(OWN_URL_MEMBER)) urls.push(member);
258
+ return [...new Set(urls)];
259
+ }
260
+ /**
261
+ * Find branches that `git push` output reports as created, updated, forced,
262
+ * or already up to date. Each ref line belongs to the nearest preceding
263
+ * `To <remote>` line. Rejected and deleted refs, new tags, and explicit refs
264
+ * outside `refs/heads/` are excluded. Unqualified tag updates cannot be
265
+ * distinguished from branch updates and are included.
266
+ * @param text - rendered shell result, stdout and stderr.
267
+ * @returns distinct pushed branches in order of first appearance.
268
+ */
269
+ function pushedBranches(text) {
270
+ const pushed = [];
271
+ let remote;
272
+ for (const line of text.split(/\r?\n/)) {
273
+ const target = PUSH_TARGET_LINE.exec(line);
274
+ if (target !== null) {
275
+ [, remote = ""] = target;
276
+ continue;
277
+ }
278
+ const ref = PUSH_REF_LINE.exec(line);
279
+ if (remote === void 0 || ref === null) continue;
280
+ const [, summary = "", destination = ""] = ref;
281
+ if (summary === "[new tag]") continue;
282
+ const branch = destination.startsWith("refs/heads/") ? destination.slice(11) : destination;
283
+ if (branch.startsWith("refs/")) continue;
284
+ if (!pushed.some((entry) => entry.remote === remote && entry.branch === branch)) pushed.push({
285
+ remote,
286
+ branch
287
+ });
288
+ }
289
+ return pushed;
290
+ }
291
+ /**
292
+ * Compare two change request URLs ignoring scheme, letter case, query,
293
+ * fragment, and trailing slashes.
294
+ * @param left - one URL.
295
+ * @param right - the other URL.
296
+ * @returns whether both name the same web page.
297
+ */
298
+ function sameChangeRequestUrl(left, right) {
299
+ return urlIdentity(left) === urlIdentity(right);
300
+ }
301
+ function urlIdentity(url) {
302
+ return url.replace(/^https?:\/\//i, "").replace(/[?#].*$/s, "").replace(/\/+$/, "").toLowerCase();
303
+ }
304
+ //#endregion
305
+ //#region lib/types/projection.js
306
+ /** Projection that folds shell results and binding records into one Session's change request bindings, and shows clients the bindings. */
307
+ /** Plugin record type of binding actions. */
308
+ const BINDING_RECORD = "plugin:forge-session/binding";
309
+ /** Tools whose calls run shell commands with a string `command` argument. */
310
+ const SHELL_TOOLS = new Set(["bash", "pwsh"]);
311
+ const EMPTY = Object.freeze({
312
+ pending: Object.freeze([]),
313
+ bindings: Object.freeze([]),
314
+ pushes: Object.freeze([])
315
+ });
316
+ const bindingRecordSchema = z$1.object({
317
+ action: z$1.enum([
318
+ "track",
319
+ "dismiss",
320
+ "discover",
321
+ "reference"
322
+ ]),
323
+ url: z$1.string().regex(/^https?:\/\/\S+$/)
324
+ });
325
+ const bindingsSchema = z$1.array(z$1.object({
326
+ url: z$1.string(),
327
+ origin: z$1.enum([
328
+ "created",
329
+ "referenced",
330
+ "discovered",
331
+ "tracked"
332
+ ]),
333
+ dismissed: z$1.boolean()
334
+ }).strict());
335
+ /**
336
+ * The `forgeChangeRequests` unit. A `bash` or `pwsh` call whose command is
337
+ * `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, or
338
+ * `git push` stays pending until its first result. A successful result binds
339
+ * every pull request URL a `gh pr create` result prints; for the other `gh pr`
340
+ * commands it binds the URL the command names and the URL the output prints
341
+ * for its own change request; and it records the branches it reports as
342
+ * pushed. Failed tool executions bind nothing; a nonzero exit status does not
343
+ * matter, because the URL or ref line is the evidence. `turn/end` drops calls
344
+ * that never received a result. Binding records bind, restore, and hide URLs.
345
+ * Clients, including the Session summary, see the bindings; the array keeps
346
+ * its reference while only pending calls and pushes change.
347
+ */
348
+ const forgeChangeRequestsProjection = {
349
+ key: "forgeChangeRequests",
350
+ stateVersion: 3,
351
+ stateSchema: z$1.object({
352
+ pending: z$1.array(z$1.object({
353
+ callId: z$1.string(),
354
+ urls: z$1.enum(["created", "referenced"]).nullable(),
355
+ pushes: z$1.boolean(),
356
+ named: z$1.array(z$1.string())
357
+ }).strict()),
358
+ bindings: bindingsSchema,
359
+ pushes: z$1.array(z$1.object({
360
+ remote: z$1.string(),
361
+ branch: z$1.string(),
362
+ seq: z$1.number().int().nonnegative().transform((value) => SessionSeq(value))
363
+ }).strict())
364
+ }).strict(),
365
+ init: () => EMPTY,
366
+ apply,
367
+ wire: {
368
+ viewSchema: bindingsSchema,
369
+ view: (state) => state.bindings
370
+ }
371
+ };
372
+ function apply(state, event) {
373
+ switch (event.type) {
374
+ case "tool/call": return applyCall(state, event.data);
375
+ case "tool/result": return applyResult(state, event.data, event.seq);
376
+ case "turn/end": return state.pending.length === 0 ? state : {
377
+ ...state,
378
+ pending: EMPTY.pending
379
+ };
380
+ default: return applyRecord(state, event);
381
+ }
382
+ }
383
+ function applyCall(state, call) {
384
+ if (!SHELL_TOOLS.has(call.name)) return state;
385
+ const command = commandOf(call.arguments);
386
+ const evidence = command === void 0 ? void 0 : commandEvidence(command);
387
+ if (evidence === void 0) return state;
388
+ return {
389
+ ...state,
390
+ pending: [...state.pending, {
391
+ callId: call.callId,
392
+ ...evidence
393
+ }]
394
+ };
395
+ }
396
+ function applyResult(state, result, seq) {
397
+ const callId = result.message.toolCallId;
398
+ const pending = state.pending.find((entry) => entry.callId === callId);
399
+ if (pending === void 0) return state;
400
+ const rest = state.pending.filter((entry) => entry !== pending);
401
+ if (result.message.isError === true) return {
402
+ ...state,
403
+ pending: rest
404
+ };
405
+ const text = result.message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n");
406
+ const printed = pending.urls === "created" ? changeRequestUrls(text) : ownChangeRequestUrls(text);
407
+ return {
408
+ pending: rest,
409
+ bindings: pending.urls === null ? state.bindings : bind(state.bindings, [...pending.named, ...printed], pending.urls),
410
+ pushes: pending.pushes ? recordPushes(state.pushes, pushedBranches(text), seq) : state.pushes
411
+ };
412
+ }
413
+ function applyRecord(state, event) {
414
+ const record = pluginRecordOf(event);
415
+ if (record?.type !== "plugin:forge-session/binding") return state;
416
+ const parsed = bindingRecordSchema.safeParse(record.data);
417
+ if (!parsed.success) return state;
418
+ const { action, url } = parsed.data;
419
+ const index = state.bindings.findIndex((binding) => sameChangeRequestUrl(binding.url, url));
420
+ const existing = state.bindings[index];
421
+ switch (action) {
422
+ case "reference": return existing === void 0 ? {
423
+ ...state,
424
+ bindings: bind(state.bindings, [url], "referenced")
425
+ } : state;
426
+ case "discover": return existing === void 0 ? {
427
+ ...state,
428
+ bindings: bind(state.bindings, [url], "discovered")
429
+ } : state;
430
+ case "track":
431
+ if (existing === void 0) return {
432
+ ...state,
433
+ bindings: bind(state.bindings, [url], "tracked")
434
+ };
435
+ return existing.dismissed ? {
436
+ ...state,
437
+ bindings: state.bindings.with(index, {
438
+ ...existing,
439
+ dismissed: false
440
+ })
441
+ } : state;
442
+ case "dismiss": return existing !== void 0 && !existing.dismissed ? {
443
+ ...state,
444
+ bindings: state.bindings.with(index, {
445
+ ...existing,
446
+ dismissed: true
447
+ })
448
+ } : state;
449
+ /* v8 ignore next -- closed BindingRecord action union exhaustiveness guard */
450
+ default: return assertNever(action, "BindingRecord.action");
451
+ }
452
+ }
453
+ /** Append each URL not already bound; returns the input array when nothing is new. */
454
+ function bind(bindings, urls, origin) {
455
+ let next = bindings;
456
+ for (const url of urls) if (!next.some((binding) => sameChangeRequestUrl(binding.url, url))) next = [...next, {
457
+ url,
458
+ origin,
459
+ dismissed: false
460
+ }];
461
+ return next;
462
+ }
463
+ /** Move each reported branch to the end with the reporting event's sequence number. */
464
+ function recordPushes(pushes, pushed, seq) {
465
+ if (pushed.length === 0) return pushes;
466
+ return [...pushes.filter((entry) => !pushed.some((ref) => ref.remote === entry.remote && ref.branch === entry.branch)), ...pushed.map((ref) => ({
467
+ remote: ref.remote,
468
+ branch: ref.branch,
469
+ seq
470
+ }))];
471
+ }
472
+ //#endregion
473
+ //#region lib/types/reference-lookup.js
474
+ /** Resolve unqualified CLI references in the shell execution world and persist only confirmed URLs. */
475
+ const completedShell = z$1.object({
476
+ kind: z$1.literal("foreground"),
477
+ cwd: z$1.string(),
478
+ exitCode: z$1.literal(0),
479
+ signal: z$1.null(),
480
+ timedOut: z$1.literal(false),
481
+ aborted: z$1.literal(false)
482
+ });
483
+ const shellArguments = z$1.object({ command: z$1.string() });
484
+ const resolvedReference = z$1.object({ url: z$1.string().regex(/^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/[1-9]\d*\/?$/) });
485
+ /**
486
+ * Observe completed foreground Bash calls while tools and shell services exist.
487
+ * Queries use the same shell service, cwd, managed environment, and standing
488
+ * sandbox policy. Failed lookups leave bindings unchanged and log a warning.
489
+ * Cancellation leaves bindings unchanged; disposal aborts and awaits all queries.
490
+ * Replay performs no lookup.
491
+ * @param ctx - owning plugin context.
492
+ * @param timeoutMs - shell deadline for one read-only lookup.
493
+ * @param record - persists a confirmed URL without restoring dismissed bindings.
494
+ */
495
+ function observeReferences(ctx, timeoutMs, record) {
496
+ ctx.inject([
497
+ "tools",
498
+ "shell",
499
+ "shellEnv"
500
+ ], (scope) => {
501
+ const lifetime = new AbortController();
502
+ const tasks = /* @__PURE__ */ new Set();
503
+ scope.effect(() => async () => {
504
+ lifetime.abort();
505
+ await Promise.allSettled(tasks);
506
+ }, "forge-session: stop reference lookups");
507
+ scope.on("tools/result", (exec, result) => {
508
+ const task = resolve(exec, result).catch((error) => {
509
+ if (lifetime.signal.aborted || exec.signal.aborted) return;
510
+ scope.logger.warn(`forge-session: PR reference lookup failed; use a full PR URL to bind it: ${error instanceof Error ? error.message : String(error)}`);
511
+ });
512
+ tasks.add(task);
513
+ task.then(() => tasks.delete(task));
514
+ });
515
+ async function resolve(exec, result) {
516
+ if (exec.name !== "bash" || exec.agent === void 0 || exec.parent !== void 0 || result.isError) return;
517
+ const argumentsResult = shellArguments.safeParse(exec.arguments);
518
+ const shell = completedShell.safeParse(result.value);
519
+ if (!argumentsResult.success || !shell.success) return;
520
+ if (ownChangeRequestUrls(result.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n")).length > 0) return;
521
+ const lookups = ghReferences(argumentsResult.data.command).flatMap((reference) => reference.lookup === null ? [] : [reference.lookup]);
522
+ const agent = exec.agent;
523
+ const signal = AbortSignal.any([lifetime.signal, exec.signal]);
524
+ for (const command of lookups) {
525
+ signal.throwIfAborted();
526
+ const spec = scope.shell.resolve({
527
+ command,
528
+ workdir: shell.data.cwd,
529
+ timeoutMs,
530
+ signal,
531
+ dshEnv: scope.shellEnv.collect(exec),
532
+ sandboxPolicy: scope.get("sandboxPolicy")?.resolve({ session: agent.session })
533
+ });
534
+ const output = await (await scope.shell.execute(spec)).result();
535
+ signal.throwIfAborted();
536
+ if (output.exitCode !== 0 || output.signal !== null || output.timedOut || output.aborted || output.stdout.truncated) throw new Error("gh did not return a complete successful reference lookup");
537
+ const parsed = resolvedReference.parse(JSON.parse(output.stdout.text));
538
+ record(agent.session, parsed.url);
539
+ }
540
+ }
541
+ });
542
+ }
543
+ //#endregion
544
+ //#region lib/types/index.js
545
+ /** Binds change requests to the Session that created, pushed, or tracked them, and watches the bound ones. */
546
+ var __addDisposableResource = function(env, value, async) {
547
+ if (value !== null && value !== void 0) {
548
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
549
+ var dispose, inner;
550
+ if (async) {
551
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
552
+ dispose = value[Symbol.asyncDispose];
553
+ }
554
+ if (dispose === void 0) {
555
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
556
+ dispose = value[Symbol.dispose];
557
+ if (async) inner = dispose;
558
+ }
559
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
560
+ if (inner) dispose = function() {
561
+ try {
562
+ inner.call(this);
563
+ } catch (e) {
564
+ return Promise.reject(e);
565
+ }
566
+ };
567
+ env.stack.push({
568
+ value,
569
+ dispose,
570
+ async
571
+ });
572
+ } else if (async) env.stack.push({ async: true });
573
+ return value;
574
+ };
575
+ var __disposeResources = (function(SuppressedError) {
576
+ return function(env) {
577
+ function fail(e) {
578
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
579
+ env.hasError = true;
580
+ }
581
+ var r, s = 0;
582
+ function next() {
583
+ while (r = env.stack.pop()) try {
584
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
585
+ if (r.dispose) {
586
+ var result = r.dispose.call(r.value);
587
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
588
+ fail(e);
589
+ return next();
590
+ });
591
+ } else s |= 1;
592
+ } catch (e) {
593
+ fail(e);
594
+ }
595
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
596
+ if (env.hasError) throw env.error;
597
+ }
598
+ return next();
599
+ };
600
+ })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
601
+ var e = new Error(message);
602
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
603
+ });
604
+ /**
605
+ * Session-scoped change request bindings. The `forgeChangeRequests`
606
+ * projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`,
607
+ * `gh pr checkout`, and `git push` results and from
608
+ * `plugin:forge-session/binding` records. After each push result, and when a
609
+ * caller reports the branch a Session works on, the service looks up open
610
+ * change requests whose head is that branch and records the unbound ones as
611
+ * discovered. Watching a Session polls its visible change requests through
612
+ * `ctx.forge`.
613
+ */
614
+ var ForgeSessionService = class extends Service {
615
+ static inject = [
616
+ "forge",
617
+ "sessions",
618
+ "sessionProjections"
619
+ ];
620
+ static Config = z.object({ discoveryTimeoutMs: z.number().default(3e4) });
621
+ home;
622
+ discoveryTimeoutMs;
623
+ watches = /* @__PURE__ */ new Map();
624
+ discoveries = /* @__PURE__ */ new Set();
625
+ sessionLifetimes = /* @__PURE__ */ new WeakMap();
626
+ lifetime = new AbortController();
627
+ projection;
628
+ constructor(ctx, config) {
629
+ super(ctx, "forgeSession");
630
+ const { discoveryTimeoutMs } = config;
631
+ if (!Number.isSafeInteger(discoveryTimeoutMs) || discoveryTimeoutMs <= 0 || discoveryTimeoutMs > MAX_TIMER_DELAY_MS) throw new Error(`forge-session: discoveryTimeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`);
632
+ this.home = ctx;
633
+ this.discoveryTimeoutMs = discoveryTimeoutMs;
634
+ ctx.effect(() => async () => {
635
+ this.lifetime.abort(/* @__PURE__ */ new Error("forge-session service disposed"));
636
+ await Promise.allSettled(this.discoveries);
637
+ }, "forge-session: stop discovery");
638
+ }
639
+ /**
640
+ * List the change requests bound to a Session and not dismissed.
641
+ * @param session - Session whose bindings are read.
642
+ * @returns visible bindings in first-recorded order, each with its latest polling result.
643
+ */
644
+ changeRequests(session) {
645
+ return this.entries(this.state(session).bindings);
646
+ }
647
+ /**
648
+ * Bind a change request to a Session by URL, or restore a dismissed binding.
649
+ * Appends a `track` record with the provider's canonical URL unless the
650
+ * change request is already visible.
651
+ * @param session - Session that receives the binding.
652
+ * @param url - web URL of the change request.
653
+ * @throws when no registered provider recognizes the URL.
654
+ */
655
+ track(session, url) {
656
+ const ref = this.home.forge.changeRequestFromUrl(url);
657
+ if (ref === void 0) throw new Error(`forge-session: no forge provider recognizes ${JSON.stringify(url)}`);
658
+ const canonical = this.home.forge.changeRequestUrl(ref);
659
+ const existing = this.state(session).bindings.find((binding) => sameChangeRequestUrl(binding.url, canonical));
660
+ if (existing === void 0 || existing.dismissed) appendPluginRecord(session, BINDING_RECORD, {
661
+ action: "track",
662
+ url: canonical
663
+ });
664
+ }
665
+ /**
666
+ * Look up open change requests whose head is a branch the Session works on,
667
+ * and record the unbound ones as discovered, as after a push of that branch.
668
+ * A remote that no provider recognizes changes nothing; a failed lookup is
669
+ * logged and changes nothing.
670
+ * @param session - Session that receives the bindings.
671
+ * @param remote - URL of the remote that holds the branch.
672
+ * @param branch - branch name on that remote.
673
+ * @returns resolves once the lookup has recorded its bindings or failed.
674
+ */
675
+ discoverBranch(session, remote, branch) {
676
+ return this.discover(session, remote, branch);
677
+ }
678
+ /**
679
+ * Hide a visible binding. Appends a `dismiss` record; a URL that is not
680
+ * visibly bound leaves the log unchanged. Discovery never restores a
681
+ * dismissed binding; `track` does.
682
+ * @param session - Session that owns the binding.
683
+ * @param url - web URL of the change request.
684
+ */
685
+ dismiss(session, url) {
686
+ const existing = this.state(session).bindings.find((binding) => !binding.dismissed && sameChangeRequestUrl(binding.url, url));
687
+ if (existing !== void 0) appendPluginRecord(session, BINDING_RECORD, {
688
+ action: "dismiss",
689
+ url: existing.url
690
+ });
691
+ }
692
+ /**
693
+ * Watch a Session's visible change requests as an effect on the calling
694
+ * fiber. Every visible change request that a provider recognizes is polled
695
+ * through `ctx.forge` while the Session has a watcher. Listeners receive the
696
+ * complete list after each later binding or provider change, or polling result; read the
697
+ * current list with {@link changeRequests}. Listeners must not throw; a
698
+ * thrown error is logged and skipped.
699
+ * @param session - Session to watch.
700
+ * @param listener - receives the complete visible list.
701
+ * @returns the disposer that stops this watch.
702
+ */
703
+ watch(session, listener) {
704
+ const dispose = this.ctx.effect(() => {
705
+ let watch = this.watches.get(session);
706
+ if (watch === void 0) {
707
+ watch = {
708
+ listeners: /* @__PURE__ */ new Set(),
709
+ bindings: this.state(session).bindings,
710
+ forgeWatches: /* @__PURE__ */ new Map()
711
+ };
712
+ this.watches.set(session, watch);
713
+ this.sync(watch);
714
+ }
715
+ const owned = watch;
716
+ owned.listeners.add(listener);
717
+ return () => {
718
+ owned.listeners.delete(listener);
719
+ if (owned.listeners.size > 0) return;
720
+ this.watches.delete(session);
721
+ for (const unwatch of owned.forgeWatches.values()) unwatch();
722
+ owned.forgeWatches.clear();
723
+ };
724
+ }, "forgeSession.watch()");
725
+ return () => void dispose();
726
+ }
727
+ async [Service.init]() {
728
+ this.projection = await this.home.sessionProjections.register(forgeChangeRequestsProjection);
729
+ this.home.on("session/event", (session, event) => {
730
+ this.react(session, event);
731
+ });
732
+ observeReferences(this.home, this.discoveryTimeoutMs, (session, url) => {
733
+ if (!this.state(session).bindings.some((binding) => sameChangeRequestUrl(binding.url, url))) appendPluginRecord(session, BINDING_RECORD, {
734
+ action: "reference",
735
+ url
736
+ });
737
+ });
738
+ this.home.on("session/disposed", (session) => {
739
+ this.sessionLifetimes.get(session)?.abort(/* @__PURE__ */ new Error("forge-session: Session was disposed"));
740
+ this.sessionLifetimes.delete(session);
741
+ });
742
+ this.home.on("forge/providers-changed", () => {
743
+ for (const [session, watch] of [...this.watches]) {
744
+ if (this.watches.get(session) !== watch) continue;
745
+ this.sync(watch);
746
+ this.notify(watch);
747
+ }
748
+ });
749
+ }
750
+ state(session) {
751
+ return this.projection.stateOf(session);
752
+ }
753
+ entries(bindings) {
754
+ const entries = [];
755
+ for (const binding of bindings) {
756
+ if (binding.dismissed) continue;
757
+ const ref = this.home.forge.changeRequestFromUrl(binding.url) ?? null;
758
+ const update = ref === null ? null : this.home.forge.latest(ref) ?? null;
759
+ entries.push({
760
+ url: binding.url,
761
+ origin: binding.origin,
762
+ ref,
763
+ update
764
+ });
765
+ }
766
+ return entries;
767
+ }
768
+ /** Discover change requests for this result's pushes and refresh watchers after binding changes. */
769
+ react(session, event) {
770
+ if (event.type !== "tool/result" && pluginRecordOf(event)?.type !== "plugin:forge-session/binding") return;
771
+ const state = this.state(session);
772
+ for (const push of state.pushes) if (push.seq === event.seq) this.discover(session, push.remote, push.branch);
773
+ const watch = this.watches.get(session);
774
+ if (watch === void 0 || watch.bindings === state.bindings) return;
775
+ watch.bindings = state.bindings;
776
+ this.sync(watch);
777
+ this.notify(watch);
778
+ }
779
+ /** Poll exactly the visible change requests that a provider recognizes. */
780
+ sync(watch) {
781
+ const wanted = /* @__PURE__ */ new Map();
782
+ for (const entry of this.entries(watch.bindings)) if (entry.ref !== null) wanted.set(changeRequestKey(entry.ref), entry.ref);
783
+ for (const [key, unwatch] of watch.forgeWatches) {
784
+ if (wanted.has(key)) continue;
785
+ unwatch();
786
+ watch.forgeWatches.delete(key);
787
+ }
788
+ for (const [key, ref] of wanted) if (!watch.forgeWatches.has(key)) watch.forgeWatches.set(key, this.home.forge.watch(ref, () => {
789
+ this.notify(watch);
790
+ }));
791
+ }
792
+ notify(watch) {
793
+ const changeRequests = this.entries(watch.bindings);
794
+ for (const listener of [...watch.listeners]) try {
795
+ listener(changeRequests);
796
+ } catch (error) {
797
+ this.home.logger.warn(`forge-session: change request listener failed: ${String(error)}`);
798
+ }
799
+ }
800
+ discover(session, remote, branch) {
801
+ const repository = this.home.forge.repositoryFromRemote(remote);
802
+ if (repository === void 0) return Promise.resolve();
803
+ let lifetime = this.sessionLifetimes.get(session);
804
+ if (lifetime === void 0) {
805
+ lifetime = new AbortController();
806
+ this.sessionLifetimes.set(session, lifetime);
807
+ }
808
+ const signal = AbortSignal.any([this.lifetime.signal, lifetime.signal]);
809
+ const task = this.lookup(session, repository, branch, signal).catch((error) => {
810
+ if (signal.aborted) return;
811
+ this.home.logger.warn(`forge-session: change request lookup for ${branch} failed: ${error instanceof Error ? error.message : String(error)}`);
812
+ });
813
+ this.discoveries.add(task);
814
+ task.then(() => this.discoveries.delete(task));
815
+ return task;
816
+ }
817
+ async lookup(session, repository, branch, signal) {
818
+ const env_1 = {
819
+ stack: [],
820
+ error: void 0,
821
+ hasError: false
822
+ };
823
+ try {
824
+ const timeout = __addDisposableResource(env_1, deadline(signal, this.discoveryTimeoutMs, "FORGE_DISCOVERY_TIMEOUT"), false);
825
+ const refs = await this.home.forge.findByHead(repository, branch, timeout.signal);
826
+ for (const ref of refs) {
827
+ if (signal.aborted || this.home.sessions.get(session.id) !== session) return;
828
+ const url = this.home.forge.changeRequestUrl(ref);
829
+ if (this.state(session).bindings.some((binding) => sameChangeRequestUrl(binding.url, url))) continue;
830
+ appendPluginRecord(session, BINDING_RECORD, {
831
+ action: "discover",
832
+ url
833
+ });
834
+ }
835
+ } catch (e_1) {
836
+ env_1.error = e_1;
837
+ env_1.hasError = true;
838
+ } finally {
839
+ __disposeResources(env_1);
840
+ }
841
+ }
842
+ };
843
+ //#endregion
844
+ export { ForgeSessionService, ForgeSessionService as default };