@cjhyy/code-shell-server 0.9.47 → 0.9.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.
Files changed (57) hide show
  1. package/README.md +4 -0
  2. package/dist/desktop-web/http-api.d.ts +9 -0
  3. package/dist/desktop-web/http-api.js +17 -1
  4. package/dist/http-byte-range.d.ts +5 -0
  5. package/dist/http-byte-range.js +19 -0
  6. package/dist/hub/auth-store.d.ts +2 -0
  7. package/dist/hub/auth-store.js +26 -7
  8. package/dist/hub/files.js +71 -9
  9. package/dist/index.panels.d.ts +1 -0
  10. package/dist/index.panels.js +1 -0
  11. package/dist/mobile-remote/access-passcode.d.ts +5 -0
  12. package/dist/mobile-remote/access-passcode.js +48 -9
  13. package/dist/mobile-remote/codex-room-agent.js +3 -1
  14. package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
  15. package/dist/mobile-remote/mobile-chat-turn.js +15 -2
  16. package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
  17. package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
  18. package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
  19. package/dist/mobile-remote/mobile-static.d.ts +5 -6
  20. package/dist/mobile-remote/mobile-static.js +13 -14
  21. package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
  22. package/dist/mobile-remote/remote-host-manager.js +132 -56
  23. package/dist/mobile-remote/resident-agent.js +3 -1
  24. package/dist/mobile-remote/room-manager.d.ts +4 -0
  25. package/dist/mobile-remote/room-manager.js +5 -0
  26. package/dist/mobile-remote/room-process-stop.d.ts +8 -0
  27. package/dist/mobile-remote/room-process-stop.js +18 -0
  28. package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
  29. package/dist/mobile-remote/tunnel-manager.js +45 -20
  30. package/dist/panels/background-service-process.d.ts +39 -0
  31. package/dist/panels/background-service-process.js +457 -0
  32. package/dist/panels/background-service-store.d.ts +67 -0
  33. package/dist/panels/background-service-store.js +539 -0
  34. package/dist/panels/background-services.d.ts +193 -0
  35. package/dist/panels/background-services.js +1159 -0
  36. package/dist/panels/bridge-contract.d.ts +1 -1
  37. package/dist/panels/execution-gate.d.ts +10 -2
  38. package/dist/panels/execution-gate.js +36 -2
  39. package/dist/panels/http.d.ts +1 -0
  40. package/dist/panels/management-http.d.ts +1 -0
  41. package/dist/panels/management.d.ts +3 -0
  42. package/dist/panels/management.js +105 -21
  43. package/dist/panels/package-mutation.d.ts +1 -1
  44. package/dist/panels/package-mutation.js +3 -10
  45. package/dist/panels/process-files.js +28 -3
  46. package/dist/panels/resources/library.d.ts +1 -4
  47. package/dist/panels/resources/library.js +2 -18
  48. package/dist/panels/resources/references.js +21 -8
  49. package/dist/panels/runtime.js +1 -1
  50. package/dist/panels/tool-jobs.d.ts +2 -0
  51. package/dist/panels/tool-jobs.js +1 -1
  52. package/dist/serve/headless-server.js +113 -79
  53. package/dist/sessions-service.d.ts +4 -0
  54. package/dist/sessions-service.js +17 -0
  55. package/dist/static-files.d.ts +7 -0
  56. package/dist/static-files.js +114 -0
  57. package/package.json +6 -6
@@ -11,7 +11,7 @@ export declare const panelBridgeLimits: Readonly<{
11
11
  callTimeoutMs: 15000;
12
12
  consentTimeoutMs: number;
13
13
  }>;
14
- export type PanelBridgeErrorCode = "PERMISSION_DENIED" | "REVOKED" | "SESSION_CHANGED" | "NOT_SUPPORTED" | "RATE_LIMITED" | "PARAMS_TOO_LARGE" | "RESULT_TOO_LARGE" | "TIMEOUT" | "INVALID_ARGUMENT" | "OPERATION_FAILED";
14
+ export type PanelBridgeErrorCode = "PERMISSION_DENIED" | "REVOKED" | "SESSION_CHANGED" | "NOT_SUPPORTED" | "RATE_LIMITED" | "PARAMS_TOO_LARGE" | "RESULT_TOO_LARGE" | "TIMEOUT" | "INVALID_ARGUMENT" | "OPERATION_FAILED" | "SERVICE_CAPACITY" | "SERVICE_CONFIG_CONFLICT" | "SERVICE_REVISION_CONFLICT" | "SERVICE_REQUEST_KEY_CONFLICT";
15
15
  export declare class PanelBridgeError extends Error {
16
16
  readonly code: PanelBridgeErrorCode;
17
17
  readonly retryAfterMs?: number | undefined;
@@ -1,6 +1,6 @@
1
1
  /** What holds an execution, so a refused change can name it. Host-assigned only. */
2
2
  export interface PanelExecutionHolder {
3
- kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation";
3
+ kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation" | "service";
4
4
  /** Desktop WebContents id of the Panel instance that owns this execution. */
5
5
  guestId?: number;
6
6
  /** Trusted Host description, such as a native tool entry name. */
@@ -32,6 +32,12 @@ export declare class PanelExecutionBusyError extends Error {
32
32
  export declare class PanelExecutionGate {
33
33
  private readonly executions;
34
34
  private readonly sources;
35
+ private readonly settled;
36
+ private readonly serviceDrains;
37
+ registerServiceDrain(drain: (matches: (scope: PanelExecutionScope) => boolean) => Promise<void>): () => void;
38
+ /** Call only after validating the concrete authorized catalog mutation. */
39
+ drainServices(matches: (scope: PanelExecutionScope) => boolean): Promise<void>;
40
+ onMutationSettled(listener: () => void): () => void;
35
41
  private readonly mutations;
36
42
  enter(scope: PanelExecutionScope): () => void;
37
43
  run<T>(scope: PanelExecutionScope, work: () => Promise<T>): Promise<T>;
@@ -39,6 +45,8 @@ export declare class PanelExecutionGate {
39
45
  register(source: () => Iterable<PanelExecutionScope>): () => void;
40
46
  /** Executions that a change matching `matches` would wait for, grouped by holder. */
41
47
  blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
42
- mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>): Promise<T>;
48
+ mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>,
49
+ /** Trusted Host preparation that validates a review before draining existing work. */
50
+ drain?: () => Promise<void>): Promise<T>;
43
51
  }
44
52
  export declare const panelExecutionGate: PanelExecutionGate;
@@ -27,6 +27,24 @@ export class PanelExecutionBusyError extends Error {
27
27
  export class PanelExecutionGate {
28
28
  executions = new Set();
29
29
  sources = new Set();
30
+ settled = new Set();
31
+ serviceDrains = new Set();
32
+ registerServiceDrain(drain) {
33
+ this.serviceDrains.add(drain);
34
+ return () => {
35
+ this.serviceDrains.delete(drain);
36
+ };
37
+ }
38
+ /** Call only after validating the concrete authorized catalog mutation. */
39
+ async drainServices(matches) {
40
+ await Promise.all([...this.serviceDrains].map((drain) => drain(matches)));
41
+ }
42
+ onMutationSettled(listener) {
43
+ this.settled.add(listener);
44
+ return () => {
45
+ this.settled.delete(listener);
46
+ };
47
+ }
30
48
  mutations = new Set();
31
49
  enter(scope) {
32
50
  const held = {
@@ -77,19 +95,35 @@ export class PanelExecutionGate {
77
95
  }
78
96
  return [...grouped.values()];
79
97
  }
80
- async mutate(matches, work) {
98
+ async mutate(matches, work,
99
+ /** Trusted Host preparation that validates a review before draining existing work. */
100
+ drain) {
81
101
  if (this.mutations.size)
82
102
  throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
83
103
  // No await between the snapshot and installing the admission barrier.
84
104
  const blockers = this.blockers(matches);
85
- if (blockers.length)
105
+ if (blockers.length && !drain)
86
106
  throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
87
107
  this.mutations.add(matches);
88
108
  try {
109
+ if (drain) {
110
+ await drain();
111
+ const remaining = this.blockers(matches);
112
+ if (remaining.length)
113
+ throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", remaining);
114
+ }
89
115
  return await work();
90
116
  }
91
117
  finally {
92
118
  this.mutations.delete(matches);
119
+ for (const listener of this.settled) {
120
+ try {
121
+ listener();
122
+ }
123
+ catch {
124
+ /* observer */
125
+ }
126
+ }
93
127
  }
94
128
  }
95
129
  }
@@ -39,6 +39,7 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
39
39
  install(context: import("./types.js").PanelOperationContext, token: unknown, bind?: boolean, approval?: {
40
40
  overwrite?: boolean;
41
41
  expectedId?: string;
42
+ releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
42
43
  }): Promise<{
43
44
  id: string;
44
45
  packageDigest: string | undefined;
@@ -30,6 +30,7 @@ export declare function createPanelManagementHttp(options: PanelManagementHttpOp
30
30
  install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
31
31
  overwrite?: boolean;
32
32
  expectedId?: string;
33
+ releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
33
34
  }): Promise<{
34
35
  id: string;
35
36
  packageDigest: string | undefined;
@@ -1,3 +1,4 @@
1
+ import { type PanelExecutionScope } from "./execution-gate.js";
1
2
  import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
2
3
  import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
3
4
  export declare class PanelManagementError extends Error {
@@ -50,6 +51,8 @@ export declare function createPanelManagement(options: PanelManagementOptions):
50
51
  install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
51
52
  overwrite?: boolean;
52
53
  expectedId?: string;
54
+ /** Trusted Host callback, never supplied by an HTTP request body. */
55
+ releaseExecutions?: (appId: string, matches: (scope: PanelExecutionScope) => boolean) => Promise<void>;
53
56
  }): Promise<{
54
57
  id: string;
55
58
  packageDigest: string | undefined;
@@ -3,7 +3,7 @@ import { panelPackageMutationMatches } from "./package-mutation.js";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { lstatSync, mkdirSync, statSync } from "node:fs";
5
5
  import { basename, extname, isAbsolute, join, resolve } from "node:path";
6
- import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
6
+ import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, currentPanelAppPackagePins, installedPanelAppUpdatedAt, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
7
7
  import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
8
8
  import { parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
9
9
  const REVIEW_TTL = 8 * 60_000;
@@ -177,14 +177,17 @@ export function createPanelManagement(options) {
177
177
  .update(JSON.stringify({
178
178
  app,
179
179
  digest,
180
- ...(options.projectPackages
181
- ? { pin: parsePanelAppPackagePins(project ?? projectSettings())[app.id] ?? null }
182
- : {}),
180
+ ...(options.projectPackages ? { pin: selectedPin(app.id, project) } : {}),
183
181
  bound: current.boundApps.has(app.id),
184
182
  disabled: current.globalDisabledApps.has(app.id),
185
183
  }))
186
184
  .digest("hex");
187
185
  }
186
+ /** The pinned package identity; when it was pinned does not change the selection. */
187
+ function selectedPin(id, project) {
188
+ const pin = parsePanelAppPackagePins(project ?? projectSettings())[id];
189
+ return pin ? { version: pin.version, packageDigest: pin.packageDigest } : null;
190
+ }
188
191
  function projectSettings() {
189
192
  return new SettingsManager(workspace, "full").getRawForScope("project", workspace, {
190
193
  strict: true,
@@ -291,7 +294,7 @@ export function createPanelManagement(options) {
291
294
  // Never attach a new revision to stale displayed binding state. File and
292
295
  // package inspection above can yield to another device's mutation.
293
296
  for (const app of [...panels, ...issues]) {
294
- const pin = options.projectPackages ? parsePanelAppPackagePins(project)[app.id] : undefined;
297
+ const pin = options.projectPackages ? currentPanelAppPackagePins(project)[app.id] : undefined;
295
298
  if (projectState(app.id, project) !== projectState(app.id) ||
296
299
  current.globalDisabledApps.has(app.id) !== policy().globalDisabledApps.has(app.id) ||
297
300
  (pin && (pin.packageDigest !== app.packageDigest || pin.version !== app.version)))
@@ -484,7 +487,7 @@ export function createPanelManagement(options) {
484
487
  return value;
485
488
  });
486
489
  }
487
- async function mutate(context, change, work) {
490
+ async function mutate(context, change, work, prepare) {
488
491
  const expected = generation(context.ownerId);
489
492
  const guard = () => assertAuthorized(context, expected);
490
493
  const run = async () => {
@@ -493,12 +496,23 @@ export function createPanelManagement(options) {
493
496
  await guard();
494
497
  return work(guard);
495
498
  };
496
- return panelExecutionGate.mutate(panelPackageMutationMatches({ ...change, projectPath: options.bindingCwd ?? options.cwd }), () => (options.withMutation ? options.withMutation(write) : write()));
499
+ const matches = panelPackageMutationMatches({
500
+ ...change,
501
+ projectPath: options.bindingCwd ?? options.cwd,
502
+ });
503
+ return panelExecutionGate.mutate(matches, () => (options.withMutation ? options.withMutation(write) : write()), prepare ? () => prepare(guard, matches) : undefined);
497
504
  };
498
505
  const next = catalogMutation.then(run, run);
499
506
  catalogMutation = next.catch(() => undefined);
500
507
  return next;
501
508
  }
509
+ async function drainSelectedServices(id, expected, guard, matches) {
510
+ const current = await selectedApp(id);
511
+ if (revision(current.app, current.digest) !== expected)
512
+ throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
513
+ await guard();
514
+ await panelExecutionGate.drainServices(matches);
515
+ }
502
516
  function setBinding(app, bound, expected) {
503
517
  options.assertBinding?.();
504
518
  if (!hasProject)
@@ -524,7 +538,20 @@ export function createPanelManagement(options) {
524
538
  if (bound) {
525
539
  if (!app.packageDigest)
526
540
  throw new Error("Cannot bind an unverified Panel package");
527
- pins[app.id] = { version: app.version, packageDigest: app.packageDigest };
541
+ const existing = pins[app.id];
542
+ // Rebinding the package a current pin already selects changes nothing, so
543
+ // it must not invalidate other devices' revisions or pending reviews.
544
+ const unchanged = existing?.version === app.version &&
545
+ existing.packageDigest === app.packageDigest &&
546
+ !!currentPanelAppPackagePins({ panelAppPins: { [app.id]: existing } })[app.id];
547
+ if (!unchanged)
548
+ pins[app.id] = {
549
+ version: app.version,
550
+ packageDigest: app.packageDigest,
551
+ // Holds this project only until the next global install or update, so it
552
+ // must be strictly later than the install it was chosen after.
553
+ pinnedAt: new Date(Math.max(now(), (installedPanelAppUpdatedAt(app.id) ?? 0) + 1)).toISOString(),
554
+ };
528
555
  }
529
556
  else
530
557
  delete pins[app.id];
@@ -638,10 +665,15 @@ export function createPanelManagement(options) {
638
665
  throw new PanelManagementError(403, "review_owner", "请在当前设备重新审阅这个版本。");
639
666
  await assertAuthorized(context, held.generation);
640
667
  const review = held.public;
641
- return mutate(context, { appId: review.appId, kind: "binding" }, async (guard) => {
642
- await assertAuthorized(context, held.generation);
668
+ const assertReviewLease = () => {
669
+ if (closed || generation(context.ownerId) !== held.generation)
670
+ throw new PanelManagementError(401, "login_required", "登录已失效,请重新登录。");
643
671
  if (restores.get(token) !== held || held.public.expiresAt <= now())
644
672
  throw new PanelManagementError(409, "review_expired", "版本审阅已失效,请重新选择。");
673
+ };
674
+ const validateReview = async (guard) => {
675
+ await assertAuthorized(context, held.generation);
676
+ assertReviewLease();
645
677
  const current = await restorationSelection(review.appId);
646
678
  if (current.revision !== review.expectedRevision)
647
679
  throw new PanelManagementError(409, "conflict", "项目面板已改变,请重新审阅。");
@@ -650,12 +682,25 @@ export function createPanelManagement(options) {
650
682
  throw new PanelManagementError(400, "unsupported", "这个版本无法在当前环境使用。");
651
683
  await guard();
652
684
  await assertAuthorized(context, held.generation);
653
- if (restores.get(token) !== held || held.public.expiresAt <= now())
654
- throw new PanelManagementError(409, "review_expired", "版本审阅已失效,请重新选择。");
685
+ assertReviewLease();
686
+ if (projectState(review.appId) !== held.projectState)
687
+ throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新审阅。");
688
+ return target;
689
+ };
690
+ return mutate(context, { appId: review.appId, kind: "binding" }, async (guard) => {
691
+ const target = await validateReview(guard);
692
+ assertReviewLease();
655
693
  setBinding(target, true, { projectState: held.projectState });
656
694
  restores.delete(token);
657
695
  await options.onChanged?.(review.appId, "binding");
658
696
  return { id: review.appId, packageDigest: review.packageDigest };
697
+ }, async (guard, matches) => {
698
+ // A reviewed version selection changes the executor's package just like
699
+ // unbinding. Validate authority and exact retained bytes before drain,
700
+ // then revalidate them before writing. Other executions still block.
701
+ await validateReview(guard);
702
+ assertReviewLease();
703
+ await panelExecutionGate.drainServices(matches);
659
704
  });
660
705
  },
661
706
  assertAuthorized,
@@ -735,12 +780,20 @@ export function createPanelManagement(options) {
735
780
  await assertAuthorized(context, held.generation);
736
781
  if (approval?.expectedId && approval.expectedId !== held.public.preview.id)
737
782
  invalid();
783
+ if (approval?.releaseExecutions && held.public.kind !== "update")
784
+ invalid();
738
785
  if (approval && held.public.kind === "update" && !approval.overwrite)
739
786
  throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请确认项目更新。");
740
- return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
741
- await assertAuthorized(context, held.generation);
742
- if (reviews.get(token) !== held || held.public.expiresAt <= now())
787
+ let admitted = false;
788
+ const assertReviewLease = () => {
789
+ if (closed || generation(context.ownerId) !== held.generation)
790
+ throw new PanelManagementError(401, "login_required", "登录已失效,请重新登录。");
791
+ if (!admitted && (reviews.get(token) !== held || held.public.expiresAt <= now()))
743
792
  throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
793
+ };
794
+ const validateReview = async (guard) => {
795
+ await assertAuthorized(context, held.generation);
796
+ assertReviewLease();
744
797
  const id = held.public.preview.id;
745
798
  if (held.public.kind === "update") {
746
799
  const current = await selectedApp(id);
@@ -753,6 +806,12 @@ export function createPanelManagement(options) {
753
806
  if (projectState(id) !== held.projectState)
754
807
  throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
755
808
  await guard();
809
+ assertReviewLease();
810
+ };
811
+ return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
812
+ await validateReview(guard);
813
+ assertReviewLease();
814
+ const id = held.public.preview.id;
756
815
  const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
757
816
  overwrite: held.public.kind === "update",
758
817
  expectedId: id,
@@ -767,6 +826,7 @@ export function createPanelManagement(options) {
767
826
  if (projectState(id) !== held.projectState)
768
827
  throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
769
828
  await guard();
829
+ assertReviewLease();
770
830
  },
771
831
  });
772
832
  reviews.delete(token);
@@ -782,7 +842,31 @@ export function createPanelManagement(options) {
782
842
  invalidateSkillCache();
783
843
  await options.onChanged?.(id, held.public.kind);
784
844
  return { id, packageDigest: installed.packageDigest };
785
- });
845
+ }, held.public.kind === "update"
846
+ ? async (guard, matches) => {
847
+ await validateReview(guard);
848
+ // A local directory/ZIP may have changed since preview. Check its
849
+ // reviewed bytes before cancelling work, then recheck mutable
850
+ // project/catalog authority after the source read.
851
+ const preview = await previewLocalPanelApp(held.source);
852
+ if (preview.id !== held.public.preview.id || preview.reviewToken !== held.digest)
853
+ throw new PanelManagementError(409, "conflict", "来源已改变,请重新检查更新。");
854
+ await validateReview(guard);
855
+ assertReviewLease();
856
+ if (approval?.releaseExecutions) {
857
+ // Force owns the review while its holders exit. TTL expiry during
858
+ // cancellation must not invalidate consent; revocation is checked.
859
+ admitted = true;
860
+ reviews.delete(token);
861
+ await approval.releaseExecutions(held.public.preview.id, matches);
862
+ }
863
+ else {
864
+ // Ordinary updates drain only services. Other executions still
865
+ // block, and failed publication must leave this review retryable.
866
+ await panelExecutionGate.drainServices(matches);
867
+ }
868
+ }
869
+ : undefined);
786
870
  },
787
871
  async binding(context, id, bound, expected) {
788
872
  assertId(id);
@@ -793,18 +877,18 @@ export function createPanelManagement(options) {
793
877
  const current = await selectedApp(id);
794
878
  if (bound && !compatibility(current.app).supported)
795
879
  throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
796
- // Legacy installs need a retained copy before the project can pin them.
797
- // An already pinned project must keep its selected version, not the catalog's.
880
+ // An unpinned (or superseded) selection needs a retained copy before the
881
+ // project can pin it. A current pin must keep its selected version.
798
882
  const app = bound &&
799
883
  options.projectPackages &&
800
- !parsePanelAppPackagePins(projectSettings())[current.app.id]
884
+ !currentPanelAppPackagePins(projectSettings())[current.app.id]
801
885
  ? await retainInstalledPanelApp(current.app.id, current.app.packageDigest)
802
886
  : current.app;
803
887
  await guard();
804
888
  setBinding(app, bound, { ...current, revision: expected });
805
889
  await options.onChanged?.(id, "binding");
806
890
  return snapshot();
807
- });
891
+ }, (guard, matches) => drainSelectedServices(id, expected, guard, matches));
808
892
  },
809
893
  async remove(context, id, expected) {
810
894
  assertId(id);
@@ -830,7 +914,7 @@ export function createPanelManagement(options) {
830
914
  invalidateSkillCache();
831
915
  await options.onChanged?.(id, "remove");
832
916
  return { removed: true };
833
- });
917
+ }, (guard, matches) => drainSelectedServices(id, expected, guard, matches));
834
918
  },
835
919
  cancelOwner(owner) {
836
920
  generations.set(owner, generation(owner) + 1);
@@ -4,5 +4,5 @@ export interface PanelPackageMutation {
4
4
  projectPath: string;
5
5
  kind: "install" | "update" | "binding" | "remove";
6
6
  }
7
- /** Pinned projects retain their bytes when a different project updates the catalog. */
7
+ /** A catalog install or update supersedes every project's pin, so it reaches every project. */
8
8
  export declare function panelPackageMutationMatches(change: PanelPackageMutation): (scope: PanelExecutionScope) => boolean;
@@ -1,6 +1,6 @@
1
- import { projectPanelAppPackagePins, resolvePanelAppBindingProjectPath, } from "@cjhyy/code-shell-core";
1
+ import { resolvePanelAppBindingProjectPath } from "@cjhyy/code-shell-core";
2
2
  import { panelExecutionProject } from "./execution-gate.js";
3
- /** Pinned projects retain their bytes when a different project updates the catalog. */
3
+ /** A catalog install or update supersedes every project's pin, so it reaches every project. */
4
4
  export function panelPackageMutationMatches(change) {
5
5
  const project = panelExecutionProject(resolvePanelAppBindingProjectPath(change.projectPath));
6
6
  return (scope) => {
@@ -11,13 +11,6 @@ export function panelPackageMutationMatches(change) {
11
11
  const bindingProject = resolvePanelAppBindingProjectPath(scope.projectPath);
12
12
  if (panelExecutionProject(bindingProject) === project)
13
13
  return true;
14
- if (change.kind === "binding")
15
- return false;
16
- try {
17
- return !projectPanelAppPackagePins(bindingProject, change.appId)[change.appId];
18
- }
19
- catch {
20
- return true;
21
- } // An unreadable project cannot prove that it is insulated.
14
+ return change.kind !== "binding";
22
15
  };
23
16
  }
@@ -3,6 +3,7 @@ import { lstat, open, opendir, realpath } from "node:fs/promises";
3
3
  import { extname, isAbsolute, join, resolve } from "node:path";
4
4
  import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
+ import { rangeBounds } from "../http-byte-range.js";
6
7
  const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
7
8
  const MAX_ENTRIES = 200;
8
9
  const MAX_EXAMINED = 4096;
@@ -227,20 +228,44 @@ export async function handlePanelProcessDirectory(request, response, options) {
227
228
  fail(404, "下载文件已经改变。");
228
229
  await verifyDirectory();
229
230
  await authorize();
230
- response.writeHead(200, {
231
+ // Directory outputs are mutable and have no stable representation validator.
232
+ // An If-Range condition cannot be confirmed, so return the complete file.
233
+ const range = request.headers["if-range"] === undefined ? request.headers.range : undefined;
234
+ const bounds = range === undefined
235
+ ? { start: 0, end: metadata.size - 1 }
236
+ : metadata.size > 0
237
+ ? rangeBounds(range, metadata.size)
238
+ : null;
239
+ const headers = {
231
240
  "Content-Type": "application/octet-stream",
232
241
  "Content-Disposition": disposition(name),
233
- "Content-Length": metadata.size,
242
+ "Accept-Ranges": "bytes",
234
243
  "Content-Security-Policy": "default-src 'none'; sandbox",
235
244
  "Cache-Control": "no-store",
236
245
  "X-Content-Type-Options": "nosniff",
237
246
  "Referrer-Policy": "no-referrer",
247
+ };
248
+ if (!bounds) {
249
+ response.writeHead(416, {
250
+ ...headers,
251
+ "Content-Range": `bytes */${metadata.size}`,
252
+ "Content-Length": 0,
253
+ });
254
+ response.end();
255
+ return;
256
+ }
257
+ response.writeHead(range === undefined ? 200 : 206, {
258
+ ...headers,
259
+ "Content-Length": bounds.end - bounds.start + 1,
260
+ ...(range === undefined
261
+ ? {}
262
+ : { "Content-Range": `bytes ${bounds.start}-${bounds.end}/${metadata.size}` }),
238
263
  });
239
264
  if (request.method === "HEAD" || metadata.size === 0) {
240
265
  response.end();
241
266
  return;
242
267
  }
243
- const input = file.createReadStream({ autoClose: false, start: 0, end: metadata.size - 1 });
268
+ const input = file.createReadStream({ autoClose: false, ...bounds });
244
269
  let checking = false;
245
270
  const timer = setInterval(() => {
246
271
  if (checking)
@@ -1,5 +1,6 @@
1
1
  import type { Readable } from "node:stream";
2
2
  import type { MediaAsset, MediaScope } from "./types.js";
3
+ export { rangeBounds } from "../../http-byte-range.js";
3
4
  export declare function resourceMimeType(name: string, requested?: string): string;
4
5
  export interface MediaLibraryOptions {
5
6
  rootDirectory: string;
@@ -33,10 +34,6 @@ export interface MediaReadResult {
33
34
  body: Readable | null;
34
35
  }
35
36
  export declare function resourceName(value: string): string;
36
- export declare function rangeBounds(range: string, size: number): {
37
- start: number;
38
- end: number;
39
- } | null;
40
37
  /**
41
38
  * Host-owned immutable, scope-local media. importFile accepts only paths already
42
39
  * authorized by the caller (for example native file selection); guest APIs must
@@ -3,6 +3,8 @@ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
4
4
  import { basename, extname, join } from "node:path";
5
5
  import { mediaDirectory, mediaScopeKey, readMediaJson, writeMediaJson } from "./storage.js";
6
+ import { rangeBounds } from "../../http-byte-range.js";
7
+ export { rangeBounds } from "../../http-byte-range.js";
6
8
  const ASSET_ID = /^asset-[a-f0-9]{64}$/;
7
9
  const MIME_BY_EXTENSION = {
8
10
  ".mp4": "video/mp4",
@@ -93,24 +95,6 @@ function assetRecord(raw, id) {
93
95
  createdAt: value.createdAt,
94
96
  };
95
97
  }
96
- export function rangeBounds(range, size) {
97
- const match = /^bytes=(\d*)-(\d*)$/.exec(range);
98
- if (!match || (!match[1] && !match[2]))
99
- return null;
100
- const first = match[1] ? Number(match[1]) : undefined;
101
- const last = match[2] ? Number(match[2]) : undefined;
102
- if ((first !== undefined && !Number.isSafeInteger(first)) ||
103
- (last !== undefined && !Number.isSafeInteger(last)))
104
- return null;
105
- if (first === undefined) {
106
- if (!last || last <= 0)
107
- return null;
108
- return { start: Math.max(0, size - last), end: size - 1 };
109
- }
110
- if (first >= size || (last !== undefined && last < first))
111
- return null;
112
- return { start: first, end: Math.min(last ?? size - 1, size - 1) };
113
- }
114
98
  /**
115
99
  * Host-owned immutable, scope-local media. importFile accepts only paths already
116
100
  * authorized by the caller (for example native file selection); guest APIs must
@@ -25,12 +25,11 @@ function referenceId(value) {
25
25
  function validIdentity(value) {
26
26
  return value && Number.isSafeInteger(value.dev) && Number.isSafeInteger(value.ino);
27
27
  }
28
- function sameSource(a, b, relocating = false) {
29
- return (a.dev === b.dev &&
30
- a.ino === b.ino &&
31
- a.size === b.size &&
32
- a.mtimeMs === b.mtimeMs &&
33
- (relocating || a.ctimeMs === b.ctimeMs));
28
+ // Like other editors, recognize an original by file, size and modification time.
29
+ // ctime is not identity: macOS changes it for tags, "last opened" and other
30
+ // extended attributes, which would otherwise turn unchanged originals "changed".
31
+ function sameSource(a, b) {
32
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
34
33
  }
35
34
  function view(record, state = "available") {
36
35
  return {
@@ -221,7 +220,21 @@ export class ExternalResourceReferences {
221
220
  if (error.code !== "ENOENT")
222
221
  throw error;
223
222
  }
224
- if ((await readdir(directory)).filter((name) => REFERENCE_ID.test(name.replace(/\.json$/, ""))).length >= MAX_REFERENCES)
223
+ const names = (await readdir(directory)).filter((name) => REFERENCE_ID.test(name.replace(/\.json$/, "")));
224
+ // IDs include the ctime seen at creation. Choosing the same unchanged
225
+ // original again must return its reference, not a duplicate asset.
226
+ for (const name of names) {
227
+ const existing = await this.record(scope, name.replace(/\.json$/, "")).catch(() => undefined);
228
+ if (existing?.root === root &&
229
+ existing.path === path &&
230
+ sameSource(existing.source, selected.initial) &&
231
+ existing.ancestors.length === selected.directory.identities.length &&
232
+ existing.ancestors.every((identity, index) => sameResourceIdentity(identity, selected.directory.identities[index]))) {
233
+ await selected.verify();
234
+ return view(existing);
235
+ }
236
+ }
237
+ if (names.length >= MAX_REFERENCES)
225
238
  throw new Error("External reference count limit exceeded");
226
239
  const record = {
227
240
  schemaVersion: 1,
@@ -302,7 +315,7 @@ export class ExternalResourceReferences {
302
315
  const selected = await this.selected(scope, root, resourceRelativePath(path), options);
303
316
  try {
304
317
  // Metadata alone cannot establish that an unrelated replacement is the original file.
305
- if (!sameSource(previous.source, selected.initial, true))
318
+ if (!sameSource(previous.source, selected.initial))
306
319
  throw new Error("This is a different file; create a new external reference instead");
307
320
  const record = {
308
321
  ...previous,
@@ -349,7 +349,7 @@ function bridgeScript(id, origin) {
349
349
  call: ask,
350
350
  callResult: (method, params) => ask(method, params).then(value => ({ ok: true, value }), error => ({ ok: false, error: { code: error.code || "OPERATION_FAILED", message: error.message, ...(typeof error.retryAfterMs === "number" ? { retryAfterMs: error.retryAfterMs } : {}) } })),
351
351
  on: (name, listener) => {
352
- if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "tasks.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
352
+ if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "tasks.changed", "services.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
353
353
  throw new Error("Invalid event listener");
354
354
  }
355
355
  let list = listeners.get(name);
@@ -47,6 +47,8 @@ export interface ToolJobProgress {
47
47
  export type ToolJobStatus = "queued" | "running" | "cancelling" | "succeeded" | "failed" | "cancelled" | "interrupted";
48
48
  export interface ToolJob {
49
49
  id: string;
50
+ /** Caller-owned idempotency identity, returned with its authorized receipt. */
51
+ requestKey?: string;
50
52
  scope: ToolJobScope;
51
53
  entry: ToolJobEntry;
52
54
  package?: ToolJobPackage;
@@ -100,7 +100,7 @@ function progressValue(progress) {
100
100
  }, toolJobLimits.maxProgressBytes);
101
101
  }
102
102
  function publicJob(job) {
103
- const { version: _version, requestKey: _requestKey, requestDigest: _requestDigest, ...value } = job;
103
+ const { version: _version, requestDigest: _requestDigest, ...value } = job;
104
104
  return toolJobJson(value, toolJobLimits.maxRecordBytes);
105
105
  }
106
106
  function packageValue(value) {