@marktrue/dsh-mobile-remote 3.2.3-rc.1 → 3.2.4-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { basename, isAbsolute, relative, resolve, sep } from "node:path";
3
- import { realpath } from "node:fs/promises";
3
+ import { lstat, realpath } from "node:fs/promises";
4
4
  import { createBundledGitReadProvider } from "./git-read-bundled-provider.js";
5
5
 
6
6
  const MAX_GRAPH_TIPS = 5;
7
+ const MAX_TRACKED_FILES = 200;
8
+ const FILE_HISTORY_DEFAULT_LIMIT = 20;
9
+ const FILE_HISTORY_MAX_LIMIT = 100;
10
+ const FILE_HISTORY_MAX_COMMITS = 5000;
11
+ const FILE_HISTORY_TIMEOUT_MS = 20_000;
12
+ const MAX_FILE_HISTORY_SNAPSHOTS = 8;
13
+ const REF_KIND_ORDER = { head: 0, localBranch: 1, remoteBranch: 2, other: 3 };
14
+ const FILE_HISTORY_REASONS = new Set(["not-tracked", "no-history", "path-invalid"]);
7
15
 
8
16
  function failure(code, status, message = code) {
9
17
  const error = new Error(message);
@@ -12,16 +20,144 @@ function failure(code, status, message = code) {
12
20
  return error;
13
21
  }
14
22
 
15
- const providerCompatible = (provider) => provider?.contractVersion === "1.0.0"
23
+ // The provider contract is additive: v1.1.0 providers expose the new methods,
24
+ // while legacy v1.0.0 providers stay compatible and their raw ref strings are
25
+ // projected into the structured DTO by this facade.
26
+ const providerCompatible = (provider) => /^1\.\d+\.\d+$/.test(String(provider?.contractVersion ?? ""))
16
27
  && ["repositoryRoot", "branches", "resolveTip", "graph", "commit"].every((method) => typeof provider[method] === "function");
17
28
 
29
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
30
+ const refShortName = (name) => name === "HEAD" ? "HEAD" : String(name).replace(/^refs\/(?:heads|remotes|tags)\//, "");
31
+ const trimSubject = (value) => [...String(value)].slice(0, 200).join("");
32
+ const emptyRefDetails = () => ({ refsDetail: [], tagsDetail: [] });
33
+ const sortRefDetails = (rows) => rows.sort((left, right) => (REF_KIND_ORDER[left.kind] ?? 9) - (REF_KIND_ORDER[right.kind] ?? 9) || compareText(left.name, right.name));
34
+ const sortTagDetails = (rows) => rows.sort((left, right) => compareText(left.name, right.name));
35
+
36
+ /** Project provider-owned ref records onto the commit they resolve to. */
37
+ function refDetailsFromRecords(records, commitOid, { fullTags = false } = {}) {
38
+ const target = String(commitOid ?? "").toLowerCase();
39
+ const refs = [], tags = [], seen = new Set();
40
+ for (const record of Array.isArray(records) ? records : []) {
41
+ if (!record || String(record.commitOid ?? "").toLowerCase() !== target) continue;
42
+ const name = typeof record.name === "string" ? record.name : "";
43
+ if (!name) continue;
44
+ const shortName = typeof record.shortName === "string" && record.shortName ? record.shortName : refShortName(name);
45
+ if (record.kind === "tag") {
46
+ const key = `tag\0${shortName}`;
47
+ if (seen.has(key)) continue;
48
+ seen.add(key);
49
+ const tag = { name, shortName, oid: target, annotated: record.annotated === true };
50
+ if (fullTags) {
51
+ if (typeof record.tagger === "string" && record.tagger) tag.tagger = record.tagger;
52
+ const date = Number(record.date);
53
+ if (Number.isFinite(date) && date > 0) tag.date = date;
54
+ if (typeof record.subject === "string" && record.subject) tag.subject = trimSubject(record.subject);
55
+ if (typeof record.hint === "string" && record.hint) tag.hint = record.hint;
56
+ }
57
+ tags.push(tag);
58
+ continue;
59
+ }
60
+ const kind = ["head", "localBranch", "remoteBranch"].includes(record.kind) ? record.kind : "other";
61
+ const key = `${kind}\0${shortName}`;
62
+ if (seen.has(key)) continue;
63
+ seen.add(key);
64
+ const detail = { kind, name, shortName, oid: target };
65
+ if (record.current === true) detail.current = true;
66
+ refs.push(detail);
67
+ }
68
+ return { refsDetail: sortRefDetails(refs), tagsDetail: sortTagDetails(tags) };
69
+ }
70
+
71
+ /**
72
+ * String fallback for legacy providers: parse raw decorations such as
73
+ * `HEAD -> refs/heads/main`, `refs/remotes/origin/main` and
74
+ * `tag: refs/tags/v1.0.0`. Annotated metadata is unavailable here, so tags
75
+ * are advertised as lightweight name-level entries.
76
+ */
77
+ function refDetailsFromLegacyRefs(rawRefs, commitOid, { tags = [] } = {}) {
78
+ const target = String(commitOid ?? "");
79
+ const refs = [], tagRows = [], seen = new Set();
80
+ let currentBranch = null;
81
+ const addRef = (kind, name, shortName, extra = {}) => {
82
+ const key = `${kind}\0${shortName}`;
83
+ if (seen.has(key)) return;
84
+ seen.add(key);
85
+ refs.push({ kind, name, shortName, oid: target, ...extra });
86
+ };
87
+ const addTag = (value) => {
88
+ let name = String(value ?? "").trim();
89
+ if (name.startsWith("tag: ")) name = name.slice(5).trim();
90
+ if (!name) return;
91
+ if (!name.startsWith("refs/tags/")) name = `refs/tags/${name}`;
92
+ const shortName = name.slice(10);
93
+ if (!shortName) return;
94
+ const key = `tag\0${shortName}`;
95
+ if (seen.has(key)) return;
96
+ seen.add(key);
97
+ tagRows.push({ name, shortName, oid: target, annotated: false });
98
+ };
99
+ const addRefValue = (value) => {
100
+ const raw = String(value ?? "").trim();
101
+ if (!raw) return;
102
+ if (raw === "HEAD") return addRef("head", "HEAD", "HEAD", { current: true });
103
+ if (raw.startsWith("HEAD -> ")) {
104
+ addRef("head", "HEAD", "HEAD", { current: true });
105
+ const next = raw.slice(8).trim();
106
+ if (!next) return undefined;
107
+ // Providers may decorate with shortened branch names (`HEAD -> main`)
108
+ // instead of full ref names; normalize the branch form here. The target
109
+ // of HEAD is by definition the current branch, marked after parsing.
110
+ if (next.startsWith("refs/heads/")) { currentBranch = next.slice(11); return addRef("localBranch", next, currentBranch); }
111
+ if (next.startsWith("refs/")) return addRefValue(next);
112
+ currentBranch = next;
113
+ return addRef("localBranch", `refs/heads/${next}`, next);
114
+ }
115
+ if (raw.startsWith("tag: ")) return addTag(raw.slice(5));
116
+ if (raw === "refs/stash") return;
117
+ if (raw.startsWith("refs/heads/")) return addRef("localBranch", raw, raw.slice(11));
118
+ if (raw.startsWith("refs/remotes/")) return raw.endsWith("/HEAD") ? undefined : addRef("remoteBranch", raw, raw.slice(13));
119
+ if (raw.startsWith("refs/tags/")) return addTag(raw);
120
+ return addRef("other", raw, raw);
121
+ };
122
+ for (const raw of Array.isArray(rawRefs) ? rawRefs : []) addRefValue(raw);
123
+ for (const raw of Array.isArray(tags) ? tags : []) addTag(raw);
124
+ // `current` is set once HEAD has been seen, so decoration order does not matter.
125
+ if (currentBranch !== null) {
126
+ const row = refs.find((entry) => entry.kind === "localBranch" && entry.shortName === currentBranch);
127
+ if (row) row.current = true;
128
+ }
129
+ return { refsDetail: sortRefDetails(refs), tagsDetail: sortTagDetails(tagRows) };
130
+ }
131
+
132
+ const mergeRefDetails = (primary, secondary) => {
133
+ const refs = new Map(), tags = new Map();
134
+ for (const row of [...primary.refsDetail, ...secondary.refsDetail]) {
135
+ const key = `${row.kind}\0${row.shortName}`;
136
+ if (!refs.has(key)) refs.set(key, row);
137
+ }
138
+ for (const row of [...primary.tagsDetail, ...secondary.tagsDetail]) {
139
+ if (!tags.has(row.shortName)) tags.set(row.shortName, row);
140
+ }
141
+ return { refsDetail: sortRefDetails([...refs.values()]), tagsDetail: sortTagDetails([...tags.values()]) };
142
+ };
143
+
144
+ const HISTORY_STATUS_WORDS = { added: "A", modified: "M", deleted: "D", renamed: "R", copied: "C", "type-changed": "T" };
145
+ const historyStatus = (value) => {
146
+ const text = String(value ?? "").trim();
147
+ const word = HISTORY_STATUS_WORDS[text.toLowerCase()];
148
+ if (word) return word;
149
+ const upper = text.toUpperCase();
150
+ return ["A", "M", "D", "R", "C", "T"].includes(upper[0]) ? upper[0] : "M";
151
+ };
152
+
18
153
  /** Session-authorized read facade over an optional external or bundled provider. */
19
154
  export function createGitReadService(ctx, { bundledProvider, now = () => Date.now(), snapshotTtlMs = 5 * 60_000, pollIntervalMs = 30_000, onChanged } = {}) {
20
155
  const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
21
156
  const external = get("gitReadProvider");
22
157
  let externalAvailable = false;
23
158
  try { externalAvailable = providerCompatible(external) && external.capabilities?.().available === true; } catch {}
24
- const provider = externalAvailable ? external : (bundledProvider ?? createBundledGitReadProvider(ctx));
159
+ const bundled = bundledProvider ?? createBundledGitReadProvider(ctx);
160
+ const provider = externalAvailable ? external : bundled;
25
161
  const bindings = new Map();
26
162
  const repositoryIdFor = (sessionId, root) => `repo_${createHash("sha256").update(`${sessionId}\0${root}`).digest("base64url").slice(0, 32)}`;
27
163
  const inside = (child, parent) => {
@@ -60,6 +196,8 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
60
196
  const worktreeSnapshots = new Map();
61
197
  const snapshots = new Map();
62
198
  const fileCursors = new Map();
199
+ const historySnapshots = new Map();
200
+ const historyCursors = new Map();
63
201
  const refSignatures = new Map();
64
202
  const keepBounded = (map, maximum) => {
65
203
  while (map.size > maximum) map.delete(map.keys().next().value);
@@ -67,6 +205,8 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
67
205
  const prune = () => {
68
206
  for (const [id, item] of snapshots) if (item.expiresAt <= now()) snapshots.delete(id);
69
207
  for (const [id, item] of fileCursors) if (item.expiresAt <= now()) fileCursors.delete(id);
208
+ for (const [id, item] of historySnapshots) if (item.expiresAt <= now()) historySnapshots.delete(id);
209
+ for (const [id, item] of historyCursors) if (item.expiresAt <= now()) historyCursors.delete(id);
70
210
  };
71
211
  let pollTimer = null;
72
212
  let unsubscribeProvider = null;
@@ -74,15 +214,68 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
74
214
  const safeFilePath = (value) => typeof value === "string" && value !== "" && !isAbsolute(value) && !/^(?:[A-Za-z]:[\\/]|\\\\|[a-z][a-z\d+.-]*:\/\/)/i.test(value) && !value.split(/[\\/]/).includes("..") && !value.includes("\0");
75
215
  const validRef = (value) => value === "HEAD" || (typeof value === "string" && /^refs\/(?:heads|remotes)\/[A-Za-z0-9._/-]+$/.test(value) && !value.includes("..") && !value.includes("//") && !value.includes("@{") && !/(^|\/)\.|\/$|\.lock(?:\/|$)|[/.]$/.test(value));
76
216
  const stale = () => failure("graph-stale", 409);
217
+ const hasNewFileMethods = (candidate) => typeof candidate?.files === "function" && typeof candidate?.fileHistory === "function";
218
+ const bundledProviderReady = () => {
219
+ try { return bundled.capabilities?.().available === true; } catch { return false; }
220
+ };
221
+ // Truthful capability: only the ACTIVE provider may serve the new endpoints.
222
+ // A legacy external provider must not be mixed with bundled data (its graph
223
+ // and commit facts come from a different source); a bundled provider also
224
+ // needs its Git subprocess to be usable.
225
+ const fileHistoryFeature = () => {
226
+ if (!hasNewFileMethods(provider)) return false;
227
+ if (provider !== bundled) return true;
228
+ return bundledProviderReady();
229
+ };
230
+ const fileReader = () => {
231
+ if (!hasNewFileMethods(provider)) throw failure("provider-incompatible", 503);
232
+ return provider;
233
+ };
234
+ const readRefRecords = async (root) => {
235
+ if (typeof provider.refs !== "function") return null;
236
+ try {
237
+ const records = await provider.refs(root);
238
+ return Array.isArray(records) ? records : null;
239
+ } catch { return null; }
240
+ };
77
241
  const capabilities = () => {
78
242
  const base = provider.capabilities?.() ?? { available: true, reason: null };
79
243
  return {
80
- contractVersion: "1.0.0", available: base.available !== false,
244
+ contractVersion: "1.1.0", available: base.available !== false,
81
245
  reason: ["git-provider-unavailable", "workspace-registry-unavailable"].includes(base.reason) ? base.reason : null,
82
246
  readOnly: true, provider: provider === external ? "external" : "bundled",
83
- features: { repository: true, branches: true, graph: true, commit: true, worktree: true, preview: true },
247
+ features: {
248
+ repository: true, branches: true, graph: true, commit: true, worktree: true, preview: true,
249
+ // Both flags are independent and additive: refsDetail can be
250
+ // synthesized from legacy strings; fileHistory is true only when the
251
+ // active provider implements the new methods (see fileHistoryFeature).
252
+ refsDetail: true, fileHistory: fileHistoryFeature(),
253
+ },
84
254
  };
85
255
  };
256
+ /**
257
+ * Normalize a client-supplied path to a repository-relative POSIX path.
258
+ * Returns null for paths that escape the repository (reported as
259
+ * `path-invalid`), and throws 400 for directories.
260
+ */
261
+ const normalizeHistoryPath = async (root, input) => {
262
+ if (typeof input !== "string" || input === "" || input.includes("\0")) return null;
263
+ // Windows-style absolute paths cannot be mapped on a POSIX host; report
264
+ // them as invalid instead of treating them as a relative filename.
265
+ if (/^(?:[A-Za-z]:[\\/]|\\\\)/.test(input) && !isAbsolute(input)) return null;
266
+ const candidate = isAbsolute(input) ? resolve(input) : resolve(root, input);
267
+ const rel = relative(root, candidate);
268
+ if (rel === "") throw failure("path-invalid", 400);
269
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
270
+ const normalized = sep === "/" ? rel : rel.split(sep).join("/");
271
+ try {
272
+ const info = await lstat(candidate);
273
+ if (info.isDirectory()) throw failure("path-invalid", 400);
274
+ } catch (error) {
275
+ if (error?.code === "path-invalid") throw error;
276
+ }
277
+ return normalized;
278
+ };
86
279
  const service = {
87
280
  capabilities,
88
281
  async capabilitiesForSession(sessionId) {
@@ -115,10 +308,10 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
115
308
  },
116
309
  async worktree(sessionId, repositoryId) {
117
310
  const authorized = await binding(sessionId, repositoryId);
118
- let reader = typeof provider.worktree === "function" ? provider : createBundledGitReadProvider(ctx);
311
+ let reader = typeof provider.worktree === "function" ? provider : bundled;
119
312
  let raw = await reader.worktree(authorized.root);
120
313
  if (typeof raw?.signature !== "string" || raw.signature === "" || !Array.isArray(raw.entries)) {
121
- reader = createBundledGitReadProvider(ctx);
314
+ reader = bundled;
122
315
  raw = await reader.worktree(authorized.root);
123
316
  }
124
317
  const entries = (raw.entries ?? []).filter((entry) => safeFilePath(entry.path)).slice(0, 2000);
@@ -130,7 +323,7 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
130
323
  async preview(sessionId, repositoryId, { kind, snapshotId, path, oid } = {}) {
131
324
  const authorized = await binding(sessionId, repositoryId);
132
325
  if (!safeFilePath(path) || !["staged", "unstaged", "untracked", "commit"].includes(kind)) throw failure("preview-invalid", 400);
133
- const reader = createBundledGitReadProvider(ctx);
326
+ const reader = bundled;
134
327
  let raw;
135
328
  if (kind === "commit") {
136
329
  if (!validOid(oid)) throw failure("invalid-oid", 400);
@@ -205,7 +398,20 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
205
398
  keepBounded(fileCursors, 1024);
206
399
  }
207
400
  const stats = allFiles.reduce((sum, file) => ({ additions: sum.additions + (file.additions ?? 0), deletions: sum.deletions + (file.deletions ?? 0), files: sum.files + 1 }), { additions: 0, deletions: 0, files: 0 });
208
- return { repositoryId, oid: String(value.oid ?? ""), parents: Array.isArray(value.parents) ? value.parents.map(String) : [], author: String(value.author ?? ""), timestamp: Number(value.timestamp) || 0, message: String(value.message ?? ""), refs: Array.isArray(value.refs) ? value.refs.map(String) : [], tags: Array.isArray(value.tags) ? value.tags.map(String) : [], stats, files: { items, total: allFiles.length, nextCursor } };
401
+ const commitOid = String(value.oid ?? oid);
402
+ const legacyRefs = Array.isArray(value.refs) ? value.refs.map(String) : [];
403
+ const legacyTags = Array.isArray(value.tags) ? value.tags.map(String) : [];
404
+ const records = await readRefRecords(authorized.root);
405
+ const refDetails = mergeRefDetails(
406
+ records ? refDetailsFromRecords(records, commitOid, { fullTags: true }) : emptyRefDetails(),
407
+ refDetailsFromLegacyRefs(legacyRefs, commitOid, { tags: legacyTags }),
408
+ );
409
+ return {
410
+ repositoryId, oid: commitOid, parents: Array.isArray(value.parents) ? value.parents.map(String) : [],
411
+ author: String(value.author ?? ""), timestamp: Number(value.timestamp) || 0, message: String(value.message ?? ""),
412
+ refs: legacyRefs, tags: legacyTags, refsDetail: refDetails.refsDetail, tagsDetail: refDetails.tagsDetail,
413
+ stats, files: { items, total: allFiles.length, nextCursor },
414
+ };
209
415
  },
210
416
  async graph(sessionId, repositoryId, { tips, snapshotId, cursor, limit = 100 } = {}) {
211
417
  const authorized = await binding(sessionId, repositoryId);
@@ -260,11 +466,22 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
260
466
  try { current = await provider.resolveTip(authorized.root, tip.name); } catch { snapshots.delete(snapshot.id); throw stale(); }
261
467
  if (current.toLowerCase() !== tip.tipOid.toLowerCase()) { snapshots.delete(snapshot.id); throw stale(); }
262
468
  }
263
- const commits = (Array.isArray(rawCommits) ? rawCommits : []).map((row) => ({
264
- oid: String(row.oid ?? ""), parents: Array.isArray(row.parents) ? row.parents.map(String) : [],
265
- author: String(row.author ?? ""), timestamp: Number(row.timestamp) || 0, subject: String(row.subject ?? ""),
266
- refs: Array.isArray(row.refs) ? row.refs.map(String) : [],
267
- }));
469
+ const records = await readRefRecords(authorized.root);
470
+ const commits = (Array.isArray(rawCommits) ? rawCommits : []).map((row) => {
471
+ const oid = String(row.oid ?? "");
472
+ const legacy = Array.isArray(row.refs) ? row.refs.map(String) : [];
473
+ // Graph rows only guarantee name-level refs plus `annotated`; full
474
+ // tag metadata is reserved for commit details.
475
+ const refDetails = mergeRefDetails(
476
+ records ? refDetailsFromRecords(records, oid) : emptyRefDetails(),
477
+ refDetailsFromLegacyRefs(legacy, oid),
478
+ );
479
+ return {
480
+ oid, parents: Array.isArray(row.parents) ? row.parents.map(String) : [],
481
+ author: String(row.author ?? ""), timestamp: Number(row.timestamp) || 0, subject: String(row.subject ?? ""),
482
+ refs: legacy, refsDetail: refDetails.refsDetail, tagsDetail: refDetails.tagsDetail,
483
+ };
484
+ });
268
485
  let nextCursor = null;
269
486
  if (commits.length === pageSize) {
270
487
  nextCursor = randomUUID();
@@ -273,6 +490,68 @@ export function createGitReadService(ctx, { bundledProvider, now = () => Date.no
273
490
  }
274
491
  return { repositoryId, snapshotId: snapshot.id, tips: snapshot.tips, commits, nextCursor };
275
492
  },
493
+ async files(sessionId, repositoryId, { q, limit } = {}) {
494
+ const authorized = await binding(sessionId, repositoryId);
495
+ const pageSize = Math.max(1, Math.min(MAX_TRACKED_FILES, Number(limit) || MAX_TRACKED_FILES));
496
+ const query = typeof q === "string" ? q : "";
497
+ const raw = await fileReader().files(authorized.root, { q: query, limit: pageSize });
498
+ const files = [...new Set((Array.isArray(raw?.files) ? raw.files : [])
499
+ .map((path) => String(path ?? ""))
500
+ .filter((path) => safeFilePath(path)))].sort(compareText).slice(0, pageSize);
501
+ return { repositoryId, files, truncated: raw?.truncated === true };
502
+ },
503
+ async fileHistory(sessionId, repositoryId, { path, cursor, limit } = {}) {
504
+ const authorized = await binding(sessionId, repositoryId);
505
+ const pageSize = Math.max(1, Math.min(FILE_HISTORY_MAX_LIMIT, Number(limit) || FILE_HISTORY_DEFAULT_LIMIT));
506
+ prune();
507
+ const normalized = await normalizeHistoryPath(authorized.root, path);
508
+ if (normalized === null) {
509
+ return { repositoryId, path: String(path ?? ""), entries: [], nextCursor: null, truncated: false, reason: "path-invalid" };
510
+ }
511
+ let snapshot = null, offset = 0;
512
+ if (cursor !== undefined && cursor !== null && cursor !== "") {
513
+ const saved = historyCursors.get(String(cursor));
514
+ if (!saved || saved.expiresAt <= now() || saved.sessionId !== sessionId || saved.repositoryId !== repositoryId || saved.path !== normalized) throw failure("invalid-history-cursor", 400);
515
+ snapshot = historySnapshots.get(saved.snapshotId);
516
+ if (!snapshot || snapshot.expiresAt <= now() || snapshot.root !== authorized.root) throw failure("invalid-history-cursor", 400);
517
+ offset = saved.offset;
518
+ }
519
+ if (!snapshot) {
520
+ const raw = await fileReader().fileHistory(authorized.root, normalized, {
521
+ maxCommits: FILE_HISTORY_MAX_COMMITS,
522
+ timeoutMs: FILE_HISTORY_TIMEOUT_MS,
523
+ });
524
+ const reason = FILE_HISTORY_REASONS.has(raw?.reason) ? raw.reason : null;
525
+ const entries = (Array.isArray(raw?.entries) ? raw.entries : []).map((entry) => ({
526
+ oid: String(entry?.oid ?? ""),
527
+ shortOid: String(entry?.shortOid ?? String(entry?.oid ?? "").slice(0, 7)),
528
+ author: String(entry?.author ?? ""),
529
+ timestamp: Number(entry?.timestamp) || 0,
530
+ subject: String(entry?.subject ?? ""),
531
+ status: historyStatus(entry?.status),
532
+ })).filter((entry) => validOid(entry.oid));
533
+ if (reason && entries.length === 0) {
534
+ return { repositoryId, path: normalized, entries: [], nextCursor: null, truncated: false, reason };
535
+ }
536
+ snapshot = {
537
+ id: randomUUID(), sessionId, repositoryId, root: authorized.root, path: normalized,
538
+ entries, truncated: raw?.truncated === true, expiresAt: now() + snapshotTtlMs,
539
+ };
540
+ historySnapshots.set(snapshot.id, snapshot);
541
+ keepBounded(historySnapshots, MAX_FILE_HISTORY_SNAPSHOTS);
542
+ }
543
+ const items = snapshot.entries.slice(offset, offset + pageSize);
544
+ let nextCursor = null;
545
+ if (offset + items.length < snapshot.entries.length) {
546
+ nextCursor = randomUUID();
547
+ historyCursors.set(nextCursor, {
548
+ sessionId, repositoryId, path: normalized, snapshotId: snapshot.id,
549
+ offset: offset + items.length, expiresAt: now() + snapshotTtlMs,
550
+ });
551
+ keepBounded(historyCursors, 1024);
552
+ }
553
+ return { repositoryId, path: normalized, entries: items, nextCursor, truncated: snapshot.truncated };
554
+ },
276
555
  async _pollOnce() {
277
556
  for (const [repositoryId, authorized] of bindings) {
278
557
  try {
package/lib/index.js CHANGED
@@ -2738,6 +2738,14 @@ export function apply(ctx, config, runtime = {}) {
2738
2738
  backgroundOnBudget: false,
2739
2739
  runDeadlineMs: budgetMs,
2740
2740
  });
2741
+ /**
2742
+ * 审批/问询在手机在线时的保持时长默认值:1 小时。
2743
+ *
2744
+ * 原本是 120 秒——人工处理一个需要看代码的审批(尤其手机上)经常来不及,
2745
+ * 超时后按 unavailable 结算,用户看到的是"莫名其妙失败了"。
2746
+ * 可用 config.pendingTimeoutMs 覆盖。
2747
+ */
2748
+ const DEFAULT_PENDING_TIMEOUT_MS = 60 * 60_000;
2741
2749
  const NOTIF_MAX = 100;
2742
2750
  let catalogCache = null; // { at, body } 15 秒 TTL
2743
2751
  let balanceCache = null; // { at, body } 余额缓存 60 秒 TTL(官方 API 抖动时兜底)
@@ -4818,7 +4826,7 @@ export function apply(ctx, config, runtime = {}) {
4818
4826
  "connection": "keep-alive",
4819
4827
  });
4820
4828
  res.write(": connected\n\n");
4821
- res.write(`data: ${JSON.stringify({ type: "hello", serverTime: Date.now(), capabilities: { eventTimeline: EVENT_TIMELINE_CAPABILITIES, turnOutline: turnOutlineCapabilityOf(ctx), fileRange: FILE_RANGE_CAPABILITIES, hold: { version: 1, onlineMs: Math.max(0, Number(config.pendingTimeoutMs ?? 120_000) || 0), offlineMs: Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0) } } })}\n\n`);
4829
+ res.write(`data: ${JSON.stringify({ type: "hello", serverTime: Date.now(), capabilities: { eventTimeline: EVENT_TIMELINE_CAPABILITIES, turnOutline: turnOutlineCapabilityOf(ctx), fileRange: FILE_RANGE_CAPABILITIES, hold: { version: 1, onlineMs: Math.max(0, Number(config.pendingTimeoutMs ?? DEFAULT_PENDING_TIMEOUT_MS) || 0), offlineMs: Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0) } } })}\n\n`);
4822
4830
  connections.add(res);
4823
4831
  // 补发断线期间挂起的问询/审批(与 PC 端 GUI 连接时的 pending 回放一致;超 TTL 的僵尸帧先清掉)
4824
4832
  for (const [key, f] of pendingFrames) {
@@ -5563,7 +5571,7 @@ export function apply(ctx, config, runtime = {}) {
5563
5571
  // v3.2.4(issue #55):手机端待办等待上限(仅供显示剩余时间,结算仍以服务端为准)。
5564
5572
  hold: {
5565
5573
  version: 1,
5566
- onlineMs: Math.max(0, Number(config.pendingTimeoutMs ?? 120_000) || 0),
5574
+ onlineMs: Math.max(0, Number(config.pendingTimeoutMs ?? DEFAULT_PENDING_TIMEOUT_MS) || 0),
5567
5575
  offlineMs: Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0),
5568
5576
  },
5569
5577
  },
@@ -5710,7 +5718,7 @@ export function apply(ctx, config, runtime = {}) {
5710
5718
  return sendJson(res, result.status, result.body);
5711
5719
  }
5712
5720
 
5713
- if (new Set(["/git/capabilities", "/git/repository", "/git/branches", "/git/graph", "/git/commit", "/git/worktree", "/git/preview"]).has(rest)) {
5721
+ if (new Set(["/git/capabilities", "/git/repository", "/git/branches", "/git/graph", "/git/commit", "/git/worktree", "/git/preview", "/git/files", "/git/file-history"]).has(rest)) {
5714
5722
  if (requireGet(method, res)) return;
5715
5723
  const sessionId = url.searchParams.get("sessionId") ?? "";
5716
5724
  if (!sessionId) return error(res, 400, "bad-request", "sessionId is required");
@@ -5724,7 +5732,12 @@ export function apply(ctx, config, runtime = {}) {
5724
5732
  if (rest === "/git/branches") value = await gitRead.branches(sessionId, repositoryId);
5725
5733
  else if (rest === "/git/worktree") value = await gitRead.worktree(sessionId, repositoryId);
5726
5734
  else if (rest === "/git/preview") value = await gitRead.preview(sessionId, repositoryId, { kind: url.searchParams.get("kind"), snapshotId: url.searchParams.get("snapshotId"), path: url.searchParams.get("path"), oid: url.searchParams.get("oid") });
5727
- else if (rest === "/git/graph") {
5735
+ else if (rest === "/git/files") value = await gitRead.files(sessionId, repositoryId, { q: url.searchParams.get("q") ?? undefined, limit: url.searchParams.get("limit") ?? undefined });
5736
+ else if (rest === "/git/file-history") {
5737
+ const path = url.searchParams.get("path");
5738
+ if (path === null || path === "") return error(res, 400, "bad-request", "path is required");
5739
+ value = await gitRead.fileHistory(sessionId, repositoryId, { path, cursor: url.searchParams.get("cursor") ?? undefined, limit: url.searchParams.get("limit") ?? undefined });
5740
+ } else if (rest === "/git/graph") {
5728
5741
  let tips;
5729
5742
  const rawTips = url.searchParams.get("tips");
5730
5743
  if (rawTips !== null) {
@@ -5738,7 +5751,7 @@ export function apply(ctx, config, runtime = {}) {
5738
5751
  } catch (err) {
5739
5752
  // External providers may include host paths or credentials in messages:
5740
5753
  // only expose stable mobile error codes, never raw provider details.
5741
- const known = new Set(["session-not-found", "workspace-not-allowed", "not-git-repository", "git-provider-unavailable", "repository-not-authorized", "graph-stale", "graph-tip-invalid", "graph-too-many-tips", "invalid-oid", "invalid-files-cursor", "git-command-failed", "git-output-too-large", "provider-incompatible", "preview-invalid", "preview-file-not-found"]);
5754
+ const known = new Set(["session-not-found", "workspace-not-allowed", "not-git-repository", "git-provider-unavailable", "repository-not-authorized", "graph-stale", "graph-tip-invalid", "graph-too-many-tips", "invalid-oid", "invalid-files-cursor", "invalid-history-cursor", "path-invalid", "git-command-failed", "git-output-too-large", "provider-incompatible", "preview-invalid", "preview-file-not-found"]);
5742
5755
  const code = known.has(err?.code) ? err.code : "git-provider-unavailable";
5743
5756
  const status = Number(err?.status);
5744
5757
  ctx.logger.warn?.(`mobile-remote: Git read failed (${code}): ${err?.message ?? err}`);
@@ -8437,7 +8450,7 @@ export function apply(ctx, config, runtime = {}) {
8437
8450
  // desktop —— 一律 next() 交桌面 GUI(手机不弹审批/问询卡),适合常驻电脑前。
8438
8451
  const approvalMode = config.approvalMode ?? "both";
8439
8452
  /** 手机待办超时 fail-close(默认沿用 120s;v3.2.4 / issue #55 起可配)。 */
8440
- const PENDING_TIMEOUT_MS = Math.max(0, Number(config.pendingTimeoutMs ?? 120_000) || 0);
8453
+ const PENDING_TIMEOUT_MS = Math.max(0, Number(config.pendingTimeoutMs ?? DEFAULT_PENDING_TIMEOUT_MS) || 0);
8441
8454
  /** 手机离线时仍接管审批/问询的等待上限(修复:离线交桌面导致内核悬挂)。 */
8442
8455
  const OFFLINE_HOLD_MS = Math.max(0, Number(config.offlineHoldMs ?? 300_000) || 0);
8443
8456
  /** 审批/问询推送的宽限期(0 = 立即推)。 */
@@ -8451,7 +8464,7 @@ export function apply(ctx, config, runtime = {}) {
8451
8464
  * 无手机在线时,条目 + 回放帧只是"等手机回来再答"的记录:到期**成对清理**且**不结算**,
8452
8465
  * 既不给桌面端流程添乱,也不会留下"点了没反应"的死卡(条目没了帧还在)。
8453
8466
  */
8454
- const OFFLINE_PENDING_TTL_MS = 30 * 60_000;
8467
+ const OFFLINE_PENDING_TTL_MS = 70 * 60_000; // 必须 > 在线保持时长(1h),否则回放帧先于结算消失
8455
8468
  /**
8456
8469
  * 待答帧的回放存储读写(v3.1.4,issue #14 Bug2)。
8457
8470
  * 键以 phoneId 为主(与 /respond 结算同源),并兼容 rpcId 形态的旧键;
@@ -8610,9 +8623,9 @@ export function apply(ctx, config, runtime = {}) {
8610
8623
  const entry = { phoneId: phoneKey, sessionId, rpcId: phoneKey, timer: null, holdMs, settle: resolveHeld };
8611
8624
  pendingApprovals.set(phoneKey, entry);
8612
8625
  armTimeout(entry, () => {
8613
- // 手机无响应:fail-close(与内核 unavailable 语义一致),不悬挂工具调用
8614
- ctx.logger.warn?.(`mobile-remote: approval/request 手机超时未应答 → unavailable(${phoneKey})`);
8615
- finishApproval(entry, "unavailable");
8626
+ // 手机超时未应答:按「超时自动拒绝」结算(rejected),而非 unavailable——前者用户看得到「被拒绝」,后者只显示「没有答复」
8627
+ ctx.logger.warn?.(`mobile-remote: approval/request 手机超时未应答 → rejected(超时自动拒绝)(${phoneKey})`);
8628
+ finishApproval(entry, "rejected");
8616
8629
  });
8617
8630
  const requested = {
8618
8631
  type: "approval/requested", rpcId: entry.rpcId, sessionId, approvalId: phoneKey,
@@ -8700,8 +8713,8 @@ export function apply(ctx, config, runtime = {}) {
8700
8713
  armTimeout(entry, () => {
8701
8714
  // 手机在场但两端均未应答:fail-close(与 mobile 接管语义一致;另一端
8702
8715
  // 超时前先答则本端已由 cancel 帧收卡,此回调因条目删除而失效)
8703
- ctx.logger.warn?.(`mobile-remote: approval/request 双端待办超时 → unavailable(${phoneKey})`);
8704
- finishApproval(entry, "unavailable");
8716
+ ctx.logger.warn?.(`mobile-remote: approval/request 双端待办超时 → rejected(超时自动拒绝)(${phoneKey})`);
8717
+ finishApproval(entry, "rejected");
8705
8718
  });
8706
8719
  } else {
8707
8720
  armOfflineReap(entry, "a");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marktrue/dsh-mobile-remote",
3
- "version": "3.2.3-rc.1",
3
+ "version": "3.2.4-alpha.1",
4
4
  "description": "DSH Mobile:把 DeepSeek Harness 变成随身控制台——手机远程发消息派活、看进度、收通知、审批决策、管理会话;配套 Flutter App(当前 Android 端,iOS 未开发)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,7 +52,7 @@
52
52
  "qrcode"
53
53
  ],
54
54
  "optionalDependencies": {
55
- "@marktrue/dsh-mobile-remote-app": "npm:@marktrue/dsh-mobile-remote@3.2.3-rc.1-app"
55
+ "@marktrue/dsh-mobile-remote-app": "npm:@marktrue/dsh-mobile-remote@3.2.4-alpha.1-app"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@deepseek-ai/dsh-credentials": ">=0.1.0-rc.6 <0.3.0",