@norman-else/dsh-claude 0.1.47 → 0.1.49
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/bin.mjs +2 -2
- package/lib/client.d.ts +5 -4
- package/lib/client.js +454 -307
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +357 -57
- package/lib/index.mjs.map +1 -1
- package/lib/{preset-installer-C4TTNV9L.mjs → preset-installer-Cj637ucf.mjs} +2 -2
- package/lib/{preset-installer-C4TTNV9L.mjs.map → preset-installer-Cj637ucf.mjs.map} +1 -1
- package/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { A as CLAUDE_REPOSITORY_FILE_PATH, C as CLAUDE_PROMPTS_PATH, D as CLAUDE_RENDER_MODES, E as CLAUDE_PROSE_MODES, F as CLAUDE_UPDATE_CHECK_PATH, H as isClaudeAlertMode, I as CLAUDE_UPDATE_PATH, L as CLAUDE_USAGE_PATH, M as CLAUDE_REPOSITORY_STATUS_PATH, N as CLAUDE_REVIEW_COMMENT_PATH, O as CLAUDE_REPOSITORY_ACTION_PATH, P as CLAUDE_REWIND_PATH, R as DEFAULT_CLAUDE_PROSE_MODE, S as CLAUDE_PROJECTION_PATH, T as CLAUDE_PROMPT_REFINE_PATH, U as isClaudeProseMode, V as TASK_TOOL_NAMES, W as isClaudeRenderMode, _ as CLAUDE_CODE_PROVIDER_IDS, a as latestClaudeTasks, b as CLAUDE_JIRA_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ALERT_MODES, g as CLAUDE_CODE_PROVIDER, h as CLAUDE_CODE_PRESET_ID, i as latestClaudeSessionBinding, j as CLAUDE_REPOSITORY_SETUP_PATH, k as CLAUDE_REPOSITORY_FEEDBACK_PATH, l as redactText, m as CLAUDE_CLIENT_DIAGNOSTICS_PATH, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_ASK_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_DOCTOR_PATH, w as CLAUDE_PROMPT_NAME_PATH, x as CLAUDE_PLAN_FEEDBACK_PATH, y as CLAUDE_GLOBAL_SETTINGS_PATH, z as DEFAULT_CLAUDE_RENDER_MODE } from "./events-B498uofA.mjs";
|
|
2
2
|
import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-CRFcjLSC.mjs";
|
|
3
|
-
import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-
|
|
3
|
+
import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-Cj637ucf.mjs";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
5
|
import z from "@deepseek-ai/schemastery";
|
|
5
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
7
|
import { chmod, mkdir, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
-
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
8
8
|
import { dshHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -4560,8 +4560,10 @@ const MAX_UNTRACKED_DIFFS = 50;
|
|
|
4560
4560
|
const GIT_TIMEOUT_MS$3 = 5e3;
|
|
4561
4561
|
const GH_TIMEOUT_MS$1 = 8e3;
|
|
4562
4562
|
const CACHE_TTL_MS = 5e3;
|
|
4563
|
+
const PULL_REQUEST_TTL_FACTOR = 12;
|
|
4563
4564
|
const MAX_TEXT_CHARS = 1024;
|
|
4564
4565
|
const MAX_CONFLICT_PATHS = 100;
|
|
4566
|
+
const PULL_REQUEST_FIELDS = "number,title,url,state,isDraft,reviewDecision,mergeStateStatus,mergedAt,statusCheckRollup,author,createdAt,baseRefName,headRefName,additions,deletions,changedFiles";
|
|
4565
4567
|
function bounded(value) {
|
|
4566
4568
|
return value.trim().slice(0, MAX_TEXT_CHARS);
|
|
4567
4569
|
}
|
|
@@ -4761,6 +4763,7 @@ function parsePullRequest(value) {
|
|
|
4761
4763
|
review: reviewState(input.reviewDecision),
|
|
4762
4764
|
checks: aggregateChecks(input.statusCheckRollup),
|
|
4763
4765
|
...typeof input.mergeStateStatus === "string" ? { mergeState: bounded(input.mergeStateStatus) } : {},
|
|
4766
|
+
...typeof input.headRefName === "string" && input.headRefName.length > 0 ? { headBranch: bounded(input.headRefName) } : {},
|
|
4764
4767
|
...typeof record$11(input.author)?.login === "string" ? { author: bounded(String(record$11(input.author)?.login)) } : {},
|
|
4765
4768
|
...typeof input.createdAt === "string" && Number.isFinite(Date.parse(input.createdAt)) ? { createdAt: new Date(input.createdAt).toISOString() } : {},
|
|
4766
4769
|
...typeof input.mergedAt === "string" && Number.isFinite(Date.parse(input.mergedAt)) ? { mergedAt: new Date(input.mergedAt).toISOString() } : {},
|
|
@@ -4801,6 +4804,75 @@ var RepositoryStatusService = class {
|
|
|
4801
4804
|
value.catch(() => this.#cache.delete(cwd));
|
|
4802
4805
|
return value;
|
|
4803
4806
|
}
|
|
4807
|
+
/** One pull request by number, as the checkout-shaped status the panels
|
|
4808
|
+
* read: a session that opened a pull request in another repository and
|
|
4809
|
+
* then moved that checkout back to its base branch still has the pull
|
|
4810
|
+
* request. No root, no diff; `cwd` is only where gh runs. Same cache and
|
|
4811
|
+
* stabilisation as `inspect`, keyed off the pull request. */
|
|
4812
|
+
inspectPullRequest(cwd, repository, number) {
|
|
4813
|
+
const key = `gh:${repository}#${number}`;
|
|
4814
|
+
const current = this.#cache.get(key);
|
|
4815
|
+
if (current !== void 0 && current.expiresAt > Date.now()) return current.value;
|
|
4816
|
+
const value = (async () => {
|
|
4817
|
+
const gh = await this.#gh();
|
|
4818
|
+
if (gh === void 0) return {
|
|
4819
|
+
status: "unavailable",
|
|
4820
|
+
cwd
|
|
4821
|
+
};
|
|
4822
|
+
const result = await run(this.#runtime, gh, [
|
|
4823
|
+
"pr",
|
|
4824
|
+
"view",
|
|
4825
|
+
String(number),
|
|
4826
|
+
"--repo",
|
|
4827
|
+
repository,
|
|
4828
|
+
"--json",
|
|
4829
|
+
PULL_REQUEST_FIELDS
|
|
4830
|
+
], cwd, GH_TIMEOUT_MS$1);
|
|
4831
|
+
const raw = result.exitCode === 0 ? JSON.parse(result.stdout) : void 0;
|
|
4832
|
+
const pullRequest = parsePullRequest(raw);
|
|
4833
|
+
if (pullRequest === void 0) return {
|
|
4834
|
+
status: "unavailable",
|
|
4835
|
+
cwd
|
|
4836
|
+
};
|
|
4837
|
+
const counts = record$11(raw);
|
|
4838
|
+
const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
4839
|
+
const patch = await run(this.#runtime, gh, [
|
|
4840
|
+
"pr",
|
|
4841
|
+
"diff",
|
|
4842
|
+
String(number),
|
|
4843
|
+
"--repo",
|
|
4844
|
+
repository
|
|
4845
|
+
], cwd, GH_TIMEOUT_MS$1, MAX_RAW_DIFF_BYTES);
|
|
4846
|
+
const packed = patch.exitCode === 0 && !patch.lossy ? packPatchByFile(patch.stdout) : void 0;
|
|
4847
|
+
return {
|
|
4848
|
+
status: "ready",
|
|
4849
|
+
cwd,
|
|
4850
|
+
remote: repository,
|
|
4851
|
+
...pullRequest.headBranch === void 0 ? {} : { branch: pullRequest.headBranch },
|
|
4852
|
+
detached: false,
|
|
4853
|
+
worktree: false,
|
|
4854
|
+
dirty: false,
|
|
4855
|
+
pullRequest,
|
|
4856
|
+
pullRequestOnly: true,
|
|
4857
|
+
diff: {
|
|
4858
|
+
additions: count(counts?.additions),
|
|
4859
|
+
deletions: count(counts?.deletions),
|
|
4860
|
+
files: count(counts?.changedFiles),
|
|
4861
|
+
...packed === void 0 ? {} : { patch: packed.patch },
|
|
4862
|
+
...packed === void 0 || packed.elided.length === 0 ? {} : { elided: packed.elided },
|
|
4863
|
+
truncated: packed === void 0
|
|
4864
|
+
}
|
|
4865
|
+
};
|
|
4866
|
+
})().then((next) => this.#stabilize(key, next), () => ({
|
|
4867
|
+
status: "unavailable",
|
|
4868
|
+
cwd
|
|
4869
|
+
}));
|
|
4870
|
+
this.#cache.set(key, {
|
|
4871
|
+
expiresAt: Date.now() + this.#cacheTtlMs * PULL_REQUEST_TTL_FACTOR,
|
|
4872
|
+
value
|
|
4873
|
+
});
|
|
4874
|
+
return value;
|
|
4875
|
+
}
|
|
4804
4876
|
invalidate(cwd) {
|
|
4805
4877
|
this.#cache.delete(cwd);
|
|
4806
4878
|
this.#lastReady.delete(cwd);
|
|
@@ -5080,7 +5152,7 @@ var RepositoryStatusService = class {
|
|
|
5080
5152
|
"--repo",
|
|
5081
5153
|
repository,
|
|
5082
5154
|
"--json",
|
|
5083
|
-
|
|
5155
|
+
PULL_REQUEST_FIELDS
|
|
5084
5156
|
], cwd, GH_TIMEOUT_MS$1);
|
|
5085
5157
|
if (result.exitCode !== 0) return void 0;
|
|
5086
5158
|
return parsePullRequest(JSON.parse(result.stdout));
|
|
@@ -5338,9 +5410,16 @@ var RepositorySetupService = class {
|
|
|
5338
5410
|
/** Tear down a merged branch: remove a plugin worktree (and its lease and
|
|
5339
5411
|
* branch), or switch a plain checkout back to the base branch and delete
|
|
5340
5412
|
* the merged branch. Refuses dirty trees. */
|
|
5341
|
-
|
|
5413
|
+
/** `branch` names the merged branch when the checkout is no longer on it:
|
|
5414
|
+
* a session that opened a pull request in another clone switched that
|
|
5415
|
+
* clone back to base itself, and only the local branch is left to delete.
|
|
5416
|
+
* `requirePushed` is for a branch no pull request vouches for: it is only
|
|
5417
|
+
* removed once every commit on it is reachable from some remote, so a
|
|
5418
|
+
* worktree that never got as far as a pull request cannot take work with it. */
|
|
5419
|
+
async cleanupMerged(pathValue, baseBranch, branch, requirePushed = false) {
|
|
5342
5420
|
const path = safePath(pathValue);
|
|
5343
|
-
const base = safeBranch(baseBranch);
|
|
5421
|
+
const base = baseBranch === void 0 ? void 0 : safeBranch(baseBranch);
|
|
5422
|
+
const named = branch === void 0 ? void 0 : safeBranch(branch);
|
|
5344
5423
|
const git = await this.#git();
|
|
5345
5424
|
const status = await this.#run(git, [
|
|
5346
5425
|
"status",
|
|
@@ -5350,36 +5429,56 @@ var RepositorySetupService = class {
|
|
|
5350
5429
|
if (status.exitCode !== 0 || status.lossy) throw new RepositorySetupError("repository-unavailable", "The repository state is unavailable.");
|
|
5351
5430
|
if (status.stdout.trim().length > 0) throw new RepositorySetupError("dirty-workspace", "Commit or stash workspace changes before cleaning up.");
|
|
5352
5431
|
const lease = (await this.#readLeases()).find((item) => comparablePath(item.path) === comparablePath(path));
|
|
5353
|
-
if (lease !== void 0)
|
|
5354
|
-
if (
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
"
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5432
|
+
if (lease !== void 0) {
|
|
5433
|
+
if (requirePushed) await this.#requirePushed(git, lease.root, lease.branch);
|
|
5434
|
+
return this.#serialize(async () => {
|
|
5435
|
+
if ((await this.#run(git, [
|
|
5436
|
+
"worktree",
|
|
5437
|
+
"remove",
|
|
5438
|
+
"--",
|
|
5439
|
+
lease.path
|
|
5440
|
+
], lease.root)).exitCode !== 0) throw new RepositorySetupError("worktree-remove-failed", "Git could not remove the worktree.");
|
|
5441
|
+
await this.#run(git, [
|
|
5442
|
+
"branch",
|
|
5443
|
+
"-D",
|
|
5444
|
+
"--",
|
|
5445
|
+
lease.branch
|
|
5446
|
+
], lease.root).catch(() => void 0);
|
|
5447
|
+
const current = await this.#readLeases();
|
|
5448
|
+
await this.#writeLeases(current.filter((item) => item.id !== lease.id));
|
|
5449
|
+
return {
|
|
5450
|
+
mode: "worktree",
|
|
5451
|
+
root: lease.root,
|
|
5452
|
+
branch: lease.branch
|
|
5453
|
+
};
|
|
5454
|
+
});
|
|
5455
|
+
}
|
|
5374
5456
|
const root = await this.#repositoryRoot(git, path);
|
|
5457
|
+
if (base === void 0) throw new RepositorySetupError("nothing-to-clean", "A plain checkout needs the base branch to return to.");
|
|
5375
5458
|
const head = await this.#run(git, [
|
|
5376
5459
|
"symbolic-ref",
|
|
5377
5460
|
"--quiet",
|
|
5378
5461
|
"--short",
|
|
5379
5462
|
"HEAD"
|
|
5380
5463
|
], root);
|
|
5381
|
-
const
|
|
5382
|
-
if (
|
|
5464
|
+
const current = head.exitCode === 0 ? head.stdout.trim() : "";
|
|
5465
|
+
if (current.length === 0 || current === base) {
|
|
5466
|
+
if (named === void 0 || named === base) throw new RepositorySetupError("nothing-to-clean", "The checkout is already on the base branch.");
|
|
5467
|
+
if (requirePushed) await this.#requirePushed(git, root, named);
|
|
5468
|
+
await this.#run(git, [
|
|
5469
|
+
"branch",
|
|
5470
|
+
"-D",
|
|
5471
|
+
"--",
|
|
5472
|
+
named
|
|
5473
|
+
], root).catch(() => void 0);
|
|
5474
|
+
await this.#run(git, ["pull", "--ff-only"], root, GIT_FETCH_TIMEOUT_MS).catch(() => void 0);
|
|
5475
|
+
return {
|
|
5476
|
+
mode: "checkout",
|
|
5477
|
+
root,
|
|
5478
|
+
branch: named
|
|
5479
|
+
};
|
|
5480
|
+
}
|
|
5481
|
+
if (requirePushed) await this.#requirePushed(git, root, current);
|
|
5383
5482
|
if ((await this.#run(git, [
|
|
5384
5483
|
"switch",
|
|
5385
5484
|
"--",
|
|
@@ -5389,13 +5488,13 @@ var RepositorySetupService = class {
|
|
|
5389
5488
|
"branch",
|
|
5390
5489
|
"-D",
|
|
5391
5490
|
"--",
|
|
5392
|
-
|
|
5491
|
+
current
|
|
5393
5492
|
], root).catch(() => void 0);
|
|
5394
5493
|
await this.#run(git, ["pull", "--ff-only"], root, GIT_FETCH_TIMEOUT_MS).catch(() => void 0);
|
|
5395
5494
|
return {
|
|
5396
5495
|
mode: "checkout",
|
|
5397
5496
|
root,
|
|
5398
|
-
branch
|
|
5497
|
+
branch: current
|
|
5399
5498
|
};
|
|
5400
5499
|
}
|
|
5401
5500
|
bindLease(leaseId, sessionId) {
|
|
@@ -5672,6 +5771,17 @@ var RepositorySetupService = class {
|
|
|
5672
5771
|
}
|
|
5673
5772
|
return `${prefix}/${slug(baseBranch, "branch")}-${stamp}-${suffix}`;
|
|
5674
5773
|
}
|
|
5774
|
+
/** Refuse to delete a branch holding commits no remote has. */
|
|
5775
|
+
async #requirePushed(git, root, branch) {
|
|
5776
|
+
const count = await this.#run(git, [
|
|
5777
|
+
"rev-list",
|
|
5778
|
+
"--count",
|
|
5779
|
+
branch,
|
|
5780
|
+
"--not",
|
|
5781
|
+
"--remotes"
|
|
5782
|
+
], root);
|
|
5783
|
+
if (count.exitCode !== 0 || Number(count.stdout.trim()) > 0) throw new RepositorySetupError("unpushed-commits", "The branch has commits that are not on any remote; push or discard them first.");
|
|
5784
|
+
}
|
|
5675
5785
|
async #repositoryRoot(git, cwd) {
|
|
5676
5786
|
const result = await this.#run(git, [
|
|
5677
5787
|
"rev-parse",
|
|
@@ -5910,6 +6020,7 @@ var RepositoryActionService = class {
|
|
|
5910
6020
|
const merged = await this.#run(gh, [
|
|
5911
6021
|
"pr",
|
|
5912
6022
|
"merge",
|
|
6023
|
+
...request.pullNumber === void 0 ? [] : [String(request.pullNumber)],
|
|
5913
6024
|
`--${method}`,
|
|
5914
6025
|
...request.admin === true ? ["--admin"] : []
|
|
5915
6026
|
], before.root, REMOTE_TIMEOUT_MS);
|
|
@@ -6385,8 +6496,10 @@ function registerRepositorySetupRoute(ctx, service, sweep, cleaned) {
|
|
|
6385
6496
|
if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
6386
6497
|
const input = await readJson$6(io);
|
|
6387
6498
|
const path = string$2(input, "path");
|
|
6388
|
-
const
|
|
6389
|
-
|
|
6499
|
+
const branch = typeof input.branch === "string" && input.branch.length > 0 ? input.branch : void 0;
|
|
6500
|
+
const baseBranch = typeof input.baseBranch === "string" && input.baseBranch.length > 0 ? input.baseBranch : void 0;
|
|
6501
|
+
const result = await service.cleanupMerged(path, baseBranch, branch, input.requirePushed === true);
|
|
6502
|
+
cleaned?.(path, branch);
|
|
6390
6503
|
return json(res, 200, result);
|
|
6391
6504
|
}
|
|
6392
6505
|
if (pathname === `/plugins/dsh-claude/repository/setup/sweep`) {
|
|
@@ -6490,6 +6603,9 @@ function actionRequest(input) {
|
|
|
6490
6603
|
...input.admin === void 0 ? {} : typeof input.admin === "boolean" ? { admin: input.admin } : (() => {
|
|
6491
6604
|
throw new RepositoryActionError("invalid-request", "The admin field must be a boolean.");
|
|
6492
6605
|
})(),
|
|
6606
|
+
...input.pullNumber === void 0 ? {} : Number.isSafeInteger(input.pullNumber) && input.pullNumber > 0 ? { pullNumber: input.pullNumber } : (() => {
|
|
6607
|
+
throw new RepositoryActionError("invalid-request", "The pullNumber field must be a positive integer.");
|
|
6608
|
+
})(),
|
|
6493
6609
|
...input.mergeMethod === void 0 ? {} : input.mergeMethod === "merge" || input.mergeMethod === "squash" || input.mergeMethod === "rebase" ? { mergeMethod: input.mergeMethod } : (() => {
|
|
6494
6610
|
throw new RepositoryActionError("invalid-request", "The mergeMethod field is invalid.");
|
|
6495
6611
|
})()
|
|
@@ -8522,30 +8638,138 @@ const FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
8522
8638
|
"NotebookEdit"
|
|
8523
8639
|
]);
|
|
8524
8640
|
/** The activity detail is a redacted JSON string that may be cut short, so
|
|
8525
|
-
*
|
|
8641
|
+
* these match one key each rather than parsing the document. */
|
|
8526
8642
|
const PATH_KEY = /"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/u;
|
|
8527
|
-
|
|
8643
|
+
const COMMAND_KEY = /"command"\s*:\s*"((?:[^"\\]|\\.)*)"/u;
|
|
8644
|
+
/** An absolute path token in shell text: not the tail of a URL, a relative
|
|
8645
|
+
* path, a `$VAR` or a `NAME=` assignment, and not reaching into quotes or
|
|
8646
|
+
* shell punctuation. */
|
|
8647
|
+
const PATH_TOKEN = String.raw`\/[\w.@+~-]+(?:\/[\w.@+~-]+)*`;
|
|
8648
|
+
/** Where a command goes to work or writes: the shell forms Claude actually
|
|
8649
|
+
* uses under full access. Reading a path (grep, cat, ls) is not touching it. */
|
|
8650
|
+
const WRITE_CONTEXTS = [
|
|
8651
|
+
new RegExp(String.raw`(?:^|[;&|(]\s*)(?:cd|pushd)\s+(${PATH_TOKEN})`, "gmu"),
|
|
8652
|
+
new RegExp(String.raw`\bgit\s+-C\s+(${PATH_TOKEN})`, "gu"),
|
|
8653
|
+
new RegExp(String.raw`\bgit\s+worktree\s+add\s+(?:-\S+\s+)*(${PATH_TOKEN})`, "gu"),
|
|
8654
|
+
new RegExp(String.raw`>{1,2}\s*(${PATH_TOKEN})`, "gu"),
|
|
8655
|
+
new RegExp(String.raw`\btee\s+(?:-\S+\s+)*(${PATH_TOKEN})`, "gu"),
|
|
8656
|
+
new RegExp(String.raw`\bmkdir\s+(?:-\S+\s+)*(${PATH_TOKEN})`, "gu"),
|
|
8657
|
+
new RegExp(String.raw`\bsed\s+-i\S*\s+(?:(?:'[^']*'|"[^"]*"|\S+)\s+)+?(${PATH_TOKEN})`, "gu"),
|
|
8658
|
+
new RegExp(String.raw`\b(?:cp|mv|install)\s+(?:\S+\s+)+?(${PATH_TOKEN})`, "gu"),
|
|
8659
|
+
new RegExp(String.raw`\bopen\(\s*['"](${PATH_TOKEN})['"]\s*,\s*['"][wa]`, "gu")
|
|
8660
|
+
];
|
|
8661
|
+
/** A pathological command (a generated file list) must not turn into a
|
|
8662
|
+
* hundred git probes. */
|
|
8663
|
+
const MAX_PATHS_PER_COMMAND = 50;
|
|
8664
|
+
const PULL_REQUEST_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d{1,9})(?![\d])/gu;
|
|
8665
|
+
const MAX_PULL_REQUESTS = 12;
|
|
8666
|
+
function unescaped(escaped) {
|
|
8667
|
+
try {
|
|
8668
|
+
return JSON.parse(`"${escaped}"`);
|
|
8669
|
+
} catch {
|
|
8670
|
+
return;
|
|
8671
|
+
}
|
|
8672
|
+
}
|
|
8673
|
+
/** Absolute paths Claude worked in or wrote to, first-seen order: the file
|
|
8674
|
+
* tools' own argument, plus the paths a Bash command changes into, sets a
|
|
8675
|
+
* worktree up at, or writes -- under full access Claude edits through
|
|
8676
|
+
* heredocs and sed. Paths outside any repository are dropped downstream. */
|
|
8528
8677
|
function touchedFilePaths(activities) {
|
|
8529
8678
|
const paths = /* @__PURE__ */ new Set();
|
|
8530
8679
|
for (const activity of activities) {
|
|
8531
|
-
if (activity.kind !== "tool-call" && activity.kind !== "subagent" || activity.toolName === void 0 ||
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8680
|
+
if (activity.kind !== "tool-call" && activity.kind !== "subagent" || activity.toolName === void 0 || activity.detail === void 0) continue;
|
|
8681
|
+
if (FILE_TOOLS.has(activity.toolName)) {
|
|
8682
|
+
const escaped = PATH_KEY.exec(activity.detail)?.[1];
|
|
8683
|
+
const path = escaped === void 0 ? void 0 : unescaped(escaped);
|
|
8684
|
+
if (path !== void 0 && isAbsolute(path)) paths.add(path);
|
|
8685
|
+
} else if (activity.toolName === "Bash") {
|
|
8686
|
+
const escaped = COMMAND_KEY.exec(activity.detail)?.[1];
|
|
8687
|
+
const command = escaped === void 0 ? void 0 : unescaped(escaped);
|
|
8688
|
+
if (command === void 0) continue;
|
|
8689
|
+
const found = [];
|
|
8690
|
+
for (const context of WRITE_CONTEXTS) for (const match of command.matchAll(context)) {
|
|
8691
|
+
const path = match[1];
|
|
8692
|
+
if (path !== void 0 && !path.startsWith("/dev/")) found.push({
|
|
8693
|
+
index: match.index + match[0].length - path.length,
|
|
8694
|
+
path
|
|
8695
|
+
});
|
|
8696
|
+
}
|
|
8697
|
+
found.sort((left, right) => left.index - right.index);
|
|
8698
|
+
for (const { path } of found.slice(0, MAX_PATHS_PER_COMMAND)) paths.add(path);
|
|
8699
|
+
}
|
|
8538
8700
|
}
|
|
8539
8701
|
return [...paths];
|
|
8540
8702
|
}
|
|
8703
|
+
const PR_CREATE = /\bgh\s+pr\s+create\b/u;
|
|
8704
|
+
/** A file a command writes (redirect, tee): where a script lands. */
|
|
8705
|
+
const WRITTEN_FILE = new RegExp(String.raw`(?:>{1,2}\s*|\btee\s+(?:-\S+\s+)*)(${PATH_TOKEN}|[\w.@+~-]+(?:\/[\w.@+~-]+)*)`, "gu");
|
|
8706
|
+
function escapeRegExp(value) {
|
|
8707
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
8708
|
+
}
|
|
8709
|
+
/** The pull requests the session opened: the URL `gh pr create` printed,
|
|
8710
|
+
* read off that call's own result. Other than the session repository's,
|
|
8711
|
+
* once each in first-seen order. A URL merely read, quoted or mentioned
|
|
8712
|
+
* (a fixture, a summary, a `gh pr view`) is not one the session made.
|
|
8713
|
+
* A script the session wrote with `gh pr create` inside and then ran by
|
|
8714
|
+
* name -- once per repository, the way a fan-out gets done -- counts the
|
|
8715
|
+
* same: the URLs come out of the runs, not of the write.
|
|
8716
|
+
* A checkout that has since moved back to its base branch no longer knows
|
|
8717
|
+
* about its pull request; the log still does. */
|
|
8718
|
+
function touchedPullRequests(activities, ownRepository) {
|
|
8719
|
+
const found = /* @__PURE__ */ new Map();
|
|
8720
|
+
const own = ownRepository?.toLowerCase();
|
|
8721
|
+
const creating = /* @__PURE__ */ new Set();
|
|
8722
|
+
/** Basenames of scripts written with `gh pr create` inside, as patterns. */
|
|
8723
|
+
const scripts = [];
|
|
8724
|
+
for (const activity of activities) {
|
|
8725
|
+
if (activity.toolUseId === void 0) continue;
|
|
8726
|
+
if (activity.kind === "tool-call" || activity.kind === "subagent" && activity.toolName !== void 0) {
|
|
8727
|
+
if (activity.toolName !== "Bash" || activity.detail === void 0) continue;
|
|
8728
|
+
const escaped = COMMAND_KEY.exec(activity.detail)?.[1];
|
|
8729
|
+
const command = escaped === void 0 ? void 0 : unescaped(escaped);
|
|
8730
|
+
if (command === void 0) continue;
|
|
8731
|
+
if (PR_CREATE.test(command)) {
|
|
8732
|
+
creating.add(activity.toolUseId);
|
|
8733
|
+
for (const match of command.matchAll(WRITTEN_FILE)) {
|
|
8734
|
+
const name = match[1]?.split("/").at(-1);
|
|
8735
|
+
if (name !== void 0 && name.length > 0) scripts.push(new RegExp(String.raw`(?:^|[\s/])${escapeRegExp(name)}(?=$|\s)`, "mu"));
|
|
8736
|
+
}
|
|
8737
|
+
} else if (scripts.some((script) => script.test(command))) creating.add(activity.toolUseId);
|
|
8738
|
+
continue;
|
|
8739
|
+
}
|
|
8740
|
+
if (activity.detail === void 0 || !creating.has(activity.toolUseId)) continue;
|
|
8741
|
+
for (const match of activity.detail.matchAll(PULL_REQUEST_URL)) {
|
|
8742
|
+
const repository = match[1]?.toLowerCase();
|
|
8743
|
+
const number = Number(match[2]);
|
|
8744
|
+
if (repository === void 0 || repository === own || !Number.isSafeInteger(number) || number <= 0) continue;
|
|
8745
|
+
const key = `${repository}#${number}`;
|
|
8746
|
+
if (found.has(key)) continue;
|
|
8747
|
+
found.set(key, {
|
|
8748
|
+
repository,
|
|
8749
|
+
number
|
|
8750
|
+
});
|
|
8751
|
+
if (found.size >= MAX_PULL_REQUESTS) return [...found.values()];
|
|
8752
|
+
}
|
|
8753
|
+
}
|
|
8754
|
+
return [...found.values()];
|
|
8755
|
+
}
|
|
8756
|
+
async function isDirectory(path) {
|
|
8757
|
+
try {
|
|
8758
|
+
return (await stat(path)).isDirectory();
|
|
8759
|
+
} catch {
|
|
8760
|
+
return false;
|
|
8761
|
+
}
|
|
8762
|
+
}
|
|
8541
8763
|
/** Repository roots behind the touched paths, minus the session's own, in
|
|
8542
8764
|
* first-seen order. `rootOf` answers undefined outside any repository. */
|
|
8543
|
-
async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max) {
|
|
8765
|
+
async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max, directory = isDirectory) {
|
|
8544
8766
|
const roots = [];
|
|
8545
|
-
const directories = new Set(
|
|
8767
|
+
const directories = /* @__PURE__ */ new Set();
|
|
8768
|
+
for (const path of paths) directories.add(await directory(path) ? path : dirname(path));
|
|
8546
8769
|
for (const directory of directories) {
|
|
8547
8770
|
const root = await rootOf(directory);
|
|
8548
8771
|
if (root === void 0 || root === sessionRoot || roots.includes(root)) continue;
|
|
8772
|
+
if (sessionRoot.startsWith(root.endsWith(sep) ? root : root + sep)) continue;
|
|
8549
8773
|
roots.push(root);
|
|
8550
8774
|
if (roots.length >= max) break;
|
|
8551
8775
|
}
|
|
@@ -8553,13 +8777,52 @@ async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max) {
|
|
|
8553
8777
|
}
|
|
8554
8778
|
/** Whether a linked checkout still has anything to show. A cleaned-up
|
|
8555
8779
|
* worktree is gone, and a checkout cleaned up in place is back on base with
|
|
8556
|
-
* nothing pending -- either way its bar comes down. A
|
|
8557
|
-
*
|
|
8780
|
+
* nothing pending -- either way its bar comes down. A checkout with an open
|
|
8781
|
+
* or merged pull request, or unpushed commits, stays. A clean branch with no
|
|
8782
|
+
* upstream is what a tool checkout looks like and is no evidence on its own. */
|
|
8558
8783
|
function linkedRepositoryShown(status) {
|
|
8559
8784
|
if (status.status !== "ready") return false;
|
|
8560
|
-
return status.dirty === true ||
|
|
8785
|
+
return status.dirty === true || (status.ahead ?? 0) > 0 || status.pullRequest !== void 0;
|
|
8561
8786
|
}
|
|
8562
8787
|
//#endregion
|
|
8788
|
+
//#region src/session-root-ledger.ts
|
|
8789
|
+
/** Which extra checkout roots a session's routes may act on.
|
|
8790
|
+
*
|
|
8791
|
+
* A session's linked repositories are re-derived on every projection sweep,
|
|
8792
|
+
* and any one sweep's git/gh probes can transiently fail -- dropping a
|
|
8793
|
+
* checkout the client is still showing a bar for. Replacing the authorised
|
|
8794
|
+
* set each sweep would then 409 that bar's controls until the next good
|
|
8795
|
+
* sweep, which reads as a control that silently does nothing. A session only
|
|
8796
|
+
* ever gains checkouts it has genuinely written into, so the ledger only
|
|
8797
|
+
* grows within a session's lifetime and never un-vouches a root a later
|
|
8798
|
+
* probe happened to miss. Bounded so a long session cannot grow it forever. */
|
|
8799
|
+
var SessionRootLedger = class {
|
|
8800
|
+
#roots = /* @__PURE__ */ new Map();
|
|
8801
|
+
#max;
|
|
8802
|
+
constructor(max) {
|
|
8803
|
+
this.#max = max;
|
|
8804
|
+
}
|
|
8805
|
+
/** Add the roots this sweep vouched for; existing ones stay. */
|
|
8806
|
+
vouch(sessionId, roots) {
|
|
8807
|
+
let set = this.#roots.get(sessionId);
|
|
8808
|
+
if (set === void 0) {
|
|
8809
|
+
set = /* @__PURE__ */ new Set();
|
|
8810
|
+
this.#roots.set(sessionId, set);
|
|
8811
|
+
}
|
|
8812
|
+
for (const root of roots) {
|
|
8813
|
+
set.delete(root);
|
|
8814
|
+
set.add(root);
|
|
8815
|
+
if (set.size > this.#max) set.delete(set.values().next().value);
|
|
8816
|
+
}
|
|
8817
|
+
}
|
|
8818
|
+
allows(sessionId, root) {
|
|
8819
|
+
return this.#roots.get(sessionId)?.has(root) === true;
|
|
8820
|
+
}
|
|
8821
|
+
forget(sessionId) {
|
|
8822
|
+
this.#roots.delete(sessionId);
|
|
8823
|
+
}
|
|
8824
|
+
};
|
|
8825
|
+
//#endregion
|
|
8563
8826
|
//#region src/update-routes.ts
|
|
8564
8827
|
const PLUGIN_PACKAGE_NAME = "@norman-else/dsh-claude";
|
|
8565
8828
|
const MAX_MANIFEST_BYTES = 262144;
|
|
@@ -9316,8 +9579,9 @@ const CLAUDE_SCOPE_UNAVAILABLE_MESSAGE = "agent command scope unavailable (prese
|
|
|
9316
9579
|
const CATALOG_RETRY_MS = 5e3;
|
|
9317
9580
|
const SCOPE_RETRY_MS = 500;
|
|
9318
9581
|
const MAX_CATALOG_RETRIES = 3;
|
|
9319
|
-
/** Bounded: each extra checkout costs a git chain and a `gh pr view` per sweep.
|
|
9320
|
-
|
|
9582
|
+
/** Bounded: each extra checkout costs a git chain and a `gh pr view` per sweep.
|
|
9583
|
+
* A fan-out over every backend service is the largest real case. */
|
|
9584
|
+
const MAX_EXTRA_REPOSITORIES = 12;
|
|
9321
9585
|
const MAX_SCOPE_RETRIES = 24;
|
|
9322
9586
|
function mountClaudeMetadata(ctx, supervisor, agent, model, sidecar, publishCommands = () => {}, resolveCommands = () => ctx.agentPresets.serviceFor(agent, CLAUDE_COMMANDS_SERVICE)) {
|
|
9323
9587
|
if (ctx.agentPresets.composedPreset(agent.ctx) !== "claude") return void 0;
|
|
@@ -9569,28 +9833,64 @@ async function apply(ctx, config) {
|
|
|
9569
9833
|
supervisor.limitsChanged();
|
|
9570
9834
|
}
|
|
9571
9835
|
});
|
|
9572
|
-
|
|
9836
|
+
/** Linked pull requests cleaned up, as `clone root + branch`: the log
|
|
9837
|
+
* still names them, so the sweep has to be told to stop listing them. */
|
|
9838
|
+
const cleanedLinked = /* @__PURE__ */ new Set();
|
|
9839
|
+
registerRepositorySetupRoute(webCtx, repositorySetup, () => sweepWorktrees?.(), (path, branch) => {
|
|
9840
|
+
repositoryStatus.invalidate(path);
|
|
9841
|
+
if (branch !== void 0) cleanedLinked.add(`${path}\0${branch}`);
|
|
9842
|
+
});
|
|
9573
9843
|
registerRepositoryStatusRoute(webCtx, repositoryStatus);
|
|
9574
9844
|
registerRepositoryFileRoute(webCtx, repositoryStatus);
|
|
9575
9845
|
registerJiraRoute(webCtx, new JiraService());
|
|
9576
9846
|
const repositoryActions = new RepositoryActionService(subprocess, supervisorConfig.executablePath, (cwd) => repositoryStatus.invalidate(cwd));
|
|
9577
|
-
/**
|
|
9578
|
-
*
|
|
9579
|
-
|
|
9847
|
+
/** Checkouts besides its own a session's routes may act on: every root a
|
|
9848
|
+
* projection sweep has ever vouched for, so a transient probe failure in
|
|
9849
|
+
* one sweep does not un-authorise a linked bar the client still shows. */
|
|
9850
|
+
const extraRoots = new SessionRootLedger(48);
|
|
9580
9851
|
const cwdForClaudeSession = (sessionId, root) => {
|
|
9581
9852
|
const agent = webCtx.agents.get(sessionId);
|
|
9582
9853
|
if (agent === void 0 || webCtx.agentPresets.composedPreset(agent.ctx) !== "claude") return void 0;
|
|
9583
9854
|
if (root === void 0) return agent.session.header.cwd;
|
|
9584
|
-
return extraRoots.
|
|
9855
|
+
return extraRoots.allows(sessionId, root) ? root : void 0;
|
|
9585
9856
|
};
|
|
9586
9857
|
const extraRepositoriesForClaudeSession = async (sessionId, activities) => {
|
|
9587
9858
|
const cwd = cwdForClaudeSession(sessionId);
|
|
9588
9859
|
if (cwd === void 0) return [];
|
|
9589
|
-
const own = await repositoryStatus.rootOf(cwd);
|
|
9860
|
+
const [own, ownStatus] = await Promise.all([repositoryStatus.rootOf(cwd), repositoryStatus.inspect(cwd)]);
|
|
9590
9861
|
const roots = await touchedRepositoryRoots(touchedFilePaths(activities), own ?? cwd, (directory) => repositoryStatus.rootOf(directory), MAX_EXTRA_REPOSITORIES);
|
|
9591
|
-
const
|
|
9592
|
-
|
|
9593
|
-
|
|
9862
|
+
const probed = await Promise.all(roots.map((root) => repositoryStatus.inspect(root)));
|
|
9863
|
+
const checkouts = probed.filter(linkedRepositoryShown);
|
|
9864
|
+
const covered = new Set(checkouts.map((status) => `${status.remote?.toLowerCase()}#${status.pullRequest?.number}`));
|
|
9865
|
+
const pullRequests = touchedPullRequests(activities, ownStatus.remote).filter((item) => !covered.has(`${item.repository}#${item.number}`)).slice(0, Math.max(0, MAX_EXTRA_REPOSITORIES - checkouts.length));
|
|
9866
|
+
const parents = [...new Set([own ?? cwd, ...probed.flatMap((status) => status.root === void 0 ? [] : [status.root])].map((root) => dirname(root)))];
|
|
9867
|
+
const cloneFor = async (item) => {
|
|
9868
|
+
const named = probed.find((status) => status.status === "ready" && status.remote?.toLowerCase() === item.repository && status.root !== void 0);
|
|
9869
|
+
if (named?.root !== void 0) return named.root;
|
|
9870
|
+
const name = item.repository.split("/").at(-1) ?? "";
|
|
9871
|
+
for (const parent of parents) {
|
|
9872
|
+
const root = await repositoryStatus.rootOf(join(parent, name));
|
|
9873
|
+
if (root === void 0) continue;
|
|
9874
|
+
const status = await repositoryStatus.inspect(root);
|
|
9875
|
+
if (status.status === "ready" && status.remote?.toLowerCase() === item.repository) return root;
|
|
9876
|
+
}
|
|
9877
|
+
};
|
|
9878
|
+
const detached = (await Promise.all(pullRequests.map(async (item) => {
|
|
9879
|
+
const cloneRoot = await cloneFor(item);
|
|
9880
|
+
const status = await repositoryStatus.inspectPullRequest(cloneRoot ?? cwd, item.repository, item.number);
|
|
9881
|
+
if (cloneRoot === void 0 || status.status !== "ready") return status;
|
|
9882
|
+
if (status.branch !== void 0 && cleanedLinked.has(`${cloneRoot}\0${status.branch}`)) return {
|
|
9883
|
+
status: "unavailable",
|
|
9884
|
+
cwd
|
|
9885
|
+
};
|
|
9886
|
+
return {
|
|
9887
|
+
...status,
|
|
9888
|
+
root: cloneRoot
|
|
9889
|
+
};
|
|
9890
|
+
}))).filter(linkedRepositoryShown);
|
|
9891
|
+
const linked = [...checkouts, ...detached];
|
|
9892
|
+
extraRoots.vouch(sessionId, linked.flatMap((status) => status.root === void 0 ? [] : [status.root]));
|
|
9893
|
+
return linked;
|
|
9594
9894
|
};
|
|
9595
9895
|
registerRepositoryActionRoute(webCtx, repositoryActions, cwdForClaudeSession);
|
|
9596
9896
|
registerClaudePromptsRoute(webCtx);
|