@cjhyy/code-shell-server 0.9.32 → 0.9.33

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,3 +1,4 @@
1
+ import type { SessionOutputCoverageStore } from "@cjhyy/code-shell-core/internal";
1
2
  export interface HubStreamCursor {
2
3
  epoch: string;
3
4
  sequence: number;
@@ -10,20 +11,36 @@ export interface HubStreamCursor {
10
11
  */
11
12
  export declare class HubRunReplay {
12
13
  private readonly limits;
14
+ private readonly persistence?;
13
15
  private readonly epoch;
14
16
  private sequence;
15
17
  private totalBytes;
16
18
  private readonly sessions;
19
+ private readonly coverage;
20
+ private coverageBytes;
17
21
  constructor(limits?: {
18
22
  maxBytes: number;
19
23
  maxEvents: number;
20
24
  maxTotalBytes?: number;
21
- });
25
+ }, persistence?: SessionOutputCoverageStore | undefined);
22
26
  begin(sessionId: string, requestId: string, baselineEvents: number): void;
23
27
  /** Only a new, still-tracked conversation can safely replay before disk publication. */
24
28
  hasEmptyBaseline(sessionId: string): boolean;
25
29
  append(sessionId: string, event: unknown): HubStreamCursor;
26
30
  finish(sessionId: string, requestId: string): void;
31
+ private retireCoverage;
32
+ outputCoverage(sessionId: string): {
33
+ streamCursor: {
34
+ epoch: `${string}-${string}-${string}-${string}-${string}`;
35
+ sequence: number;
36
+ };
37
+ outputCursor?: string | undefined;
38
+ journalIdentity?: string | undefined;
39
+ incarnation?: string | undefined;
40
+ unpaired: boolean;
41
+ inputIds: string[];
42
+ };
43
+ forget(sessionId: string): void;
27
44
  snapshot<T>(sessionId: string, durableEvents: readonly T[]): {
28
45
  liveStream?: {
29
46
  events: {
@@ -37,6 +54,17 @@ export declare class HubRunReplay {
37
54
  epoch: `${string}-${string}-${string}-${string}-${string}`;
38
55
  sequence: number;
39
56
  };
57
+ outputCoverage: {
58
+ streamCursor: {
59
+ epoch: `${string}-${string}-${string}-${string}-${string}`;
60
+ sequence: number;
61
+ };
62
+ outputCursor?: string | undefined;
63
+ journalIdentity?: string | undefined;
64
+ incarnation?: string | undefined;
65
+ unpaired: boolean;
66
+ inputIds: string[];
67
+ };
40
68
  };
41
69
  clear(): void;
42
70
  }
@@ -1,3 +1,4 @@
1
+ import { OutputCoverage } from "@cjhyy/code-shell-web";
1
2
  import { randomUUID } from "node:crypto";
2
3
  /**
3
4
  * A refresh can happen before the worker persists its current assistant text.
@@ -7,16 +8,20 @@ import { randomUUID } from "node:crypto";
7
8
  */
8
9
  export class HubRunReplay {
9
10
  limits;
11
+ persistence;
10
12
  epoch = randomUUID();
11
13
  sequence = 0;
12
14
  totalBytes = 0;
13
15
  sessions = new Map();
16
+ coverage = new Map();
17
+ coverageBytes = 0;
14
18
  constructor(limits = {
15
19
  maxBytes: 8 * 1024 * 1024,
16
20
  maxEvents: 16_000,
17
21
  maxTotalBytes: 64 * 1024 * 1024,
18
- }) {
22
+ }, persistence) {
19
23
  this.limits = limits;
24
+ this.persistence = persistence;
20
25
  }
21
26
  begin(sessionId, requestId, baselineEvents) {
22
27
  let log = this.sessions.get(sessionId);
@@ -37,6 +42,48 @@ export class HubRunReplay {
37
42
  return this.sessions.get(sessionId)?.baselineEvents === 0;
38
43
  }
39
44
  append(sessionId, event) {
45
+ let coverage = this.coverage.get(sessionId);
46
+ if (!coverage) {
47
+ coverage = { proof: new OutputCoverage() };
48
+ this.coverage.set(sessionId, coverage);
49
+ }
50
+ this.coverageBytes -= coverage.proof.memoryBytes;
51
+ const previousProof = coverage.proof;
52
+ coverage.proof = previousProof.copy();
53
+ coverage.proof.observe(event);
54
+ if (this.coverageBytes + coverage.proof.memoryBytes > 4 * 1024 * 1024)
55
+ coverage.proof.discardProof();
56
+ let withheldBeforeSession = false;
57
+ try {
58
+ if (this.persistence &&
59
+ ((coverage.proof.incomplete && !coverage.persistedBlocked) ||
60
+ coverage.proof.inputIds.length)) {
61
+ const saved = this.persistence.record(sessionId, {
62
+ unpaired: coverage.proof.incomplete,
63
+ inputIds: coverage.proof.inputIds,
64
+ });
65
+ if (!saved.incarnation) {
66
+ withheldBeforeSession = saved.status === "unavailable";
67
+ throw new Error("Session output coverage is unavailable before publication");
68
+ }
69
+ if (saved.incarnation) {
70
+ coverage.proof.acknowledgeInputs(coverage.proof.inputIds);
71
+ coverage.persistedBlocked = saved.unpaired;
72
+ }
73
+ }
74
+ }
75
+ catch (cause) {
76
+ coverage.proof = previousProof;
77
+ if (!withheldBeforeSession)
78
+ coverage.publicationFailed = true;
79
+ throw cause;
80
+ }
81
+ finally {
82
+ this.coverageBytes += coverage.proof.memoryBytes;
83
+ }
84
+ const outputCursor = event?.outputCursor;
85
+ if (typeof outputCursor === "string")
86
+ coverage.outputCursor = outputCursor;
40
87
  const sequence = ++this.sequence;
41
88
  const log = this.sessions.get(sessionId);
42
89
  if (log && !log.truncated) {
@@ -70,13 +117,53 @@ export class HubRunReplay {
70
117
  if (!log.requests.size) {
71
118
  this.totalBytes -= log.bytes;
72
119
  this.sessions.delete(sessionId);
120
+ this.retireCoverage(sessionId);
73
121
  }
74
122
  }
123
+ retireCoverage(sessionId) {
124
+ const live = this.coverage.get(sessionId);
125
+ if (!live || !this.persistence || live.publicationFailed)
126
+ return;
127
+ const saved = this.persistence.read(sessionId);
128
+ // Only disk-owned proof can outlive RAM. In-memory-only embedders retain
129
+ // their existing gap barrier, and an unknown publication failure stays live.
130
+ if (saved.status === "incomplete" ||
131
+ !saved.incarnation ||
132
+ live.proof.inputIds.length ||
133
+ (live.proof.incomplete && !saved.unpaired))
134
+ return;
135
+ this.coverageBytes -= live.proof.memoryBytes;
136
+ this.coverage.delete(sessionId);
137
+ }
138
+ outputCoverage(sessionId) {
139
+ const saved = this.persistence?.read(sessionId);
140
+ const live = this.coverage.get(sessionId);
141
+ return {
142
+ unpaired: !!saved?.unpaired || !!live?.proof.incomplete || !!live?.publicationFailed,
143
+ inputIds: [...new Set([...(saved?.inputIds ?? []), ...(live?.proof.inputIds ?? [])])],
144
+ ...(saved?.incarnation ? { incarnation: saved.incarnation } : {}),
145
+ ...(saved?.journalIdentity ? { journalIdentity: saved.journalIdentity } : {}),
146
+ ...(live?.outputCursor ? { outputCursor: live.outputCursor } : {}),
147
+ streamCursor: { epoch: this.epoch, sequence: this.sequence },
148
+ };
149
+ }
150
+ forget(sessionId) {
151
+ const log = this.sessions.get(sessionId);
152
+ if (log)
153
+ this.totalBytes -= log.bytes;
154
+ this.sessions.delete(sessionId);
155
+ const coverage = this.coverage.get(sessionId);
156
+ if (coverage)
157
+ this.coverageBytes -= coverage.proof.memoryBytes;
158
+ this.coverage.delete(sessionId);
159
+ this.persistence?.forget(sessionId);
160
+ }
75
161
  snapshot(sessionId, durableEvents) {
76
162
  const log = this.sessions.get(sessionId);
77
163
  return {
78
164
  transcript: log && !log.truncated ? durableEvents.slice(0, log.baselineEvents) : durableEvents.slice(),
79
165
  streamCursor: { epoch: this.epoch, sequence: this.sequence },
166
+ outputCoverage: this.outputCoverage(sessionId),
80
167
  ...(log
81
168
  ? { liveStream: { events: structuredClone(log.events), truncated: log.truncated } }
82
169
  : {}),
@@ -85,5 +172,7 @@ export class HubRunReplay {
85
172
  clear() {
86
173
  this.sessions.clear();
87
174
  this.totalBytes = 0;
175
+ for (const sessionId of this.coverage.keys())
176
+ this.retireCoverage(sessionId);
88
177
  }
89
178
  }
@@ -369,6 +369,8 @@ export function transcriptMarkdown(title, transcript) {
369
369
  }
370
370
  else if (item.kind === "system_error")
371
371
  sections.push(`### 错误\n\n${item.text}`);
372
+ else if (item.kind === "system")
373
+ sections.push(`### 通知\n\n${item.text}`);
372
374
  else
373
375
  sections.push(`### 子代理:${item.label}\n\n${item.status}`);
374
376
  }
@@ -3,6 +3,8 @@ export * from "./panels/automations.js";
3
3
  export * from "./panels/hub-automations.js";
4
4
  export { createPanelManagement, PanelManagementError } from "./panels/management.js";
5
5
  export { createPanelRuntime, panelWebCompatibility } from "./panels/runtime.js";
6
+ export { createHubPanelBinding } from "./panels/hub-binding.js";
7
+ export * from "./panels/project-scope.js";
6
8
  export { PanelRuntimeServices } from "./panels/runtime-services.js";
7
9
  export * from "./panels/process-service.js";
8
10
  export * from "./panels/agent-task-service.js";
@@ -3,6 +3,8 @@ export * from "./panels/automations.js";
3
3
  export * from "./panels/hub-automations.js";
4
4
  export { createPanelManagement, PanelManagementError } from "./panels/management.js";
5
5
  export { createPanelRuntime, panelWebCompatibility } from "./panels/runtime.js";
6
+ export { createHubPanelBinding } from "./panels/hub-binding.js";
7
+ export * from "./panels/project-scope.js";
6
8
  export { PanelRuntimeServices } from "./panels/runtime-services.js";
7
9
  export * from "./panels/process-service.js";
8
10
  export * from "./panels/agent-task-service.js";
@@ -7,6 +7,8 @@ export interface PanelAutomationScope {
7
7
  sessionId: string;
8
8
  /** Host-selected installed package revision, never a field from Panel call JSON. */
9
9
  revision?: string;
10
+ /** Optional synchronous transport fence immediately before a Host mutation. */
11
+ assertAuthorized?(): void;
10
12
  isAuthorized(): Promise<boolean>;
11
13
  }
12
14
  /** Borrowed Host service. A page/transport closing never stops the scheduler. */
@@ -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" | "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";
15
15
  export declare class PanelBridgeError extends Error {
16
16
  readonly code: PanelBridgeErrorCode;
17
17
  readonly retryAfterMs?: number | undefined;
@@ -77,6 +77,7 @@ export function panelRuntimeCapabilities(input) {
77
77
  errors: [
78
78
  "PERMISSION_DENIED",
79
79
  "REVOKED",
80
+ "SESSION_CHANGED",
80
81
  "NOT_SUPPORTED",
81
82
  "RATE_LIMITED",
82
83
  "PARAMS_TOO_LARGE",
@@ -4,9 +4,11 @@ import type { PanelDirectoryAuthorizer } from "./directory-bookmarks.js";
4
4
  import type { SharedPanelToolHost } from "./shared-tool-jobs.js";
5
5
  import type { PanelAutomationHost } from "./automations.js";
6
6
  import { type PanelAgentTaskHostOptions } from "./agent-task-host.js";
7
+ import type { PanelProjectScopeBinding } from "./project-scope.js";
7
8
  export declare function createPanelHttp(options: PanelManagementHttpOptions & {
8
9
  dataDir: string;
9
10
  host: "hub" | "desktop";
11
+ projectScope?: () => Promise<PanelProjectScopeBinding | undefined>;
10
12
  publicPathPrefix?: string;
11
13
  agentTaskOptions?: PanelAgentTaskHostOptions;
12
14
  sharedToolJobs?: SharedPanelToolHost;
@@ -0,0 +1,24 @@
1
+ /** Context identity only. This value grants no Host permissions. */
2
+ export interface PanelProjectScope {
3
+ version: 1;
4
+ environmentId: string;
5
+ projectId: string;
6
+ rootId: string;
7
+ }
8
+ export interface PanelProjectScopeBinding {
9
+ readonly scope: Readonly<PanelProjectScope>;
10
+ /** Revalidates the frozen authority; a failed binding can never be revived. */
11
+ assertCurrent(): void;
12
+ }
13
+ /** Only Host composition calls this factory, after resolving its selected roots. */
14
+ export declare function capturePanelProjectScope(options: {
15
+ environmentDir: string;
16
+ /** Hub supplies its already advertised descriptor identity. */
17
+ environmentId?: string;
18
+ projectPath: string;
19
+ cwd: string;
20
+ /** Desktop supplies opaque IDs resolved from its authoritative project registry. */
21
+ projectId?: string;
22
+ rootId?: string;
23
+ assertAuthority(): void;
24
+ }): Promise<PanelProjectScopeBinding>;
@@ -0,0 +1,101 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { environmentIdentity } from "../environment-identity.js";
5
+ const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
6
+ function directory(path) {
7
+ const selected = resolve(path);
8
+ const canonical = realpathSync(selected);
9
+ const info = lstatSync(canonical, { bigint: true });
10
+ if (!info.isDirectory() || info.isSymbolicLink())
11
+ throw new Error("Invalid Panel project root");
12
+ const identity = [canonical, String(info.dev), String(info.ino), String(info.birthtimeNs)];
13
+ return {
14
+ identity,
15
+ assertCurrent() {
16
+ if (realpathSync(selected) !== canonical)
17
+ throw new Error("Panel project root changed");
18
+ const current = lstatSync(canonical, { bigint: true });
19
+ if (!current.isDirectory() ||
20
+ current.isSymbolicLink() ||
21
+ current.dev !== info.dev ||
22
+ current.ino !== info.ino ||
23
+ current.birthtimeNs !== info.birthtimeNs)
24
+ throw new Error("Panel project root changed");
25
+ },
26
+ };
27
+ }
28
+ function readEnvironment(path, expectedId) {
29
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
30
+ try {
31
+ const before = fstatSync(fd, { bigint: true });
32
+ if (!before.isFile() || before.size <= 0n || before.size > 256n)
33
+ throw new Error("Invalid Panel environment identity");
34
+ const bytes = Buffer.alloc(Number(before.size));
35
+ let offset = 0;
36
+ while (offset < bytes.length) {
37
+ const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
38
+ if (!count)
39
+ throw new Error("Panel environment identity changed");
40
+ offset += count;
41
+ }
42
+ const after = fstatSync(fd, { bigint: true });
43
+ const named = lstatSync(path, { bigint: true });
44
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
45
+ if (after.dev !== before.dev ||
46
+ after.ino !== before.ino ||
47
+ after.size !== before.size ||
48
+ after.mtimeNs !== before.mtimeNs ||
49
+ after.ctimeNs !== before.ctimeNs ||
50
+ named.isSymbolicLink() ||
51
+ named.dev !== before.dev ||
52
+ named.ino !== before.ino ||
53
+ value?.version !== 1 ||
54
+ value.id !== expectedId)
55
+ throw new Error("Panel environment identity changed");
56
+ return [String(before.dev), String(before.ino), String(before.birthtimeNs)];
57
+ }
58
+ finally {
59
+ closeSync(fd);
60
+ }
61
+ }
62
+ /** Only Host composition calls this factory, after resolving its selected roots. */
63
+ export async function capturePanelProjectScope(options) {
64
+ options.assertAuthority();
65
+ const environmentId = options.environmentId ?? (await environmentIdentity(options.environmentDir));
66
+ options.assertAuthority();
67
+ const environmentRoot = directory(options.environmentDir);
68
+ const environmentFile = join(options.environmentDir, "environment.json");
69
+ const environmentPin = readEnvironment(environmentFile, environmentId);
70
+ const project = directory(options.projectPath);
71
+ const root = directory(options.cwd);
72
+ const scope = Object.freeze({
73
+ version: 1,
74
+ environmentId,
75
+ projectId: options.projectId ?? digest(["project", project.identity]),
76
+ // The registered mount and the execution root are different for worktrees.
77
+ rootId: digest(["root", options.rootId ?? null, root.identity]),
78
+ });
79
+ let revoked = false;
80
+ const binding = {
81
+ scope,
82
+ assertCurrent() {
83
+ if (revoked)
84
+ throw new Error("Panel project scope was revoked; reopen the panel");
85
+ try {
86
+ options.assertAuthority();
87
+ environmentRoot.assertCurrent();
88
+ if (digest(readEnvironment(environmentFile, environmentId)) !== digest(environmentPin))
89
+ throw new Error("Panel environment identity changed");
90
+ project.assertCurrent();
91
+ root.assertCurrent();
92
+ }
93
+ catch (cause) {
94
+ revoked = true;
95
+ throw new Error("Panel project scope changed; reopen the panel", { cause });
96
+ }
97
+ },
98
+ };
99
+ binding.assertCurrent();
100
+ return binding;
101
+ }
@@ -5,6 +5,8 @@ export interface PanelRuntimeScope {
5
5
  permissions: readonly string[];
6
6
  /** Checks the live owner, its project binding, and the installed revision. */
7
7
  isAuthorized: () => Promise<boolean>;
8
+ /** Optional synchronous transport check at the storage publication boundary. */
9
+ assertAuthorized?: () => void;
8
10
  }
9
11
  /** Node-only implementation of the existing Panel SDK's bounded disk operations. */
10
12
  export declare class PanelRuntimeServices {
@@ -157,7 +157,7 @@ export class PanelRuntimeServices {
157
157
  if (change) {
158
158
  const result = applyPanelAppStorageChange(state, change);
159
159
  if (result.updated)
160
- await writePanelAppStorage(file, state, quota, () => authorized(scope));
160
+ await writePanelAppStorage(file, state, quota, () => authorized(scope), scope.assertAuthorized);
161
161
  return result;
162
162
  }
163
163
  const existed = Object.hasOwn(state, key);
@@ -165,7 +165,7 @@ export class PanelRuntimeServices {
165
165
  state[key] = JSON.parse(encoded);
166
166
  else
167
167
  delete state[key];
168
- await writePanelAppStorage(file, state, quota, () => authorized(scope));
168
+ await writePanelAppStorage(file, state, quota, () => authorized(scope), scope.assertAuthorized);
169
169
  return method === "storage.set" ? true : existed;
170
170
  }
171
171
  finally {
@@ -4,6 +4,7 @@ import type { SharedPanelToolHost } from "./shared-tool-jobs.js";
4
4
  import { type PanelDirectoryAuthorizer } from "./directory-bookmarks.js";
5
5
  import type { PanelSnapshot } from "./types.js";
6
6
  import { type PanelAutomationHost } from "./automations.js";
7
+ import type { PanelProjectScopeBinding } from "./project-scope.js";
7
8
  export declare const panelWebCompatibility: (app: Pick<InstalledPanelApp, "permissions">, options?: {
8
9
  automations?: boolean;
9
10
  }) => {
@@ -34,6 +35,8 @@ export interface PanelRuntimeOptions {
34
35
  projectPackages?: boolean;
35
36
  dataDir: string;
36
37
  host: "hub" | "desktop";
38
+ /** Host authority only, never a field accepted from Panel HTTP requests. */
39
+ projectScope?: () => Promise<PanelProjectScopeBinding | undefined>;
37
40
  publicPathPrefix?: string;
38
41
  snapshot: (appId?: string) => Promise<PanelSnapshot>;
39
42
  ownerId: (request: IncomingMessage) => Promise<string | undefined>;
@@ -1,7 +1,7 @@
1
1
  import { panelVideoLimits, panelVideoOptions } from "./browser-capture.js";
2
2
  import { panelExecutionGate, PanelExecutionBusyError } from "./execution-gate.js";
3
3
  import { randomBytes, createHash } from "node:crypto";
4
- import { constants } from "node:fs";
4
+ import { constants, lstatSync } from "node:fs";
5
5
  import { lstat, mkdir, open, opendir, realpath } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, extname, join, resolve, sep } from "node:path";
@@ -1001,12 +1001,14 @@ export function createPanelRuntime(options) {
1001
1001
  return false;
1002
1002
  }
1003
1003
  try {
1004
+ grant.projectScope?.assertCurrent();
1004
1005
  const panel = (await snapshot(grant.app.id)).panels.find((candidate) => candidate.id === grant.app.id);
1005
1006
  const info = await lstat(grant.root);
1006
1007
  const ownerStillAuthorized = await options.isAuthorized(grant.request);
1007
1008
  const currentOwner = await options.ownerId(grant.request);
1008
1009
  if (grant.app.permissions.includes("process"))
1009
1010
  await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1011
+ grant.projectScope?.assertCurrent();
1010
1012
  const valid = !closed &&
1011
1013
  grants.get(grant.id) === grant &&
1012
1014
  grant.expiresAt > now() &&
@@ -1199,7 +1201,17 @@ export function createPanelRuntime(options) {
1199
1201
  context.sessionId = input.sessionId;
1200
1202
  context.busy = false;
1201
1203
  }
1204
+ const projectScope = app.permissions.includes("context.workspace")
1205
+ ? await options.projectScope?.()
1206
+ : undefined;
1207
+ if (projectScope) {
1208
+ projectScope.assertCurrent();
1209
+ context.projectScope = { ...projectScope.scope };
1210
+ }
1211
+ if (closed || generation !== startedGeneration || preparation.cancelled)
1212
+ error(410, "面板授权已失效,请重新打开。");
1202
1213
  const grant = {
1214
+ projectScope,
1203
1215
  sharedTools,
1204
1216
  guestId: ++nextGuest,
1205
1217
  id,
@@ -1600,12 +1612,33 @@ export function createPanelRuntime(options) {
1600
1612
  error(400, "面板通知内容无效。");
1601
1613
  return { effect: method, title: value.title ?? grant.app.title.default, body: value.body };
1602
1614
  }
1615
+ const expectedRevision = grant.revision;
1603
1616
  return services.call({
1604
1617
  appId: grant.app.id,
1605
1618
  cwd: options.cwd,
1606
1619
  projectPath: options.bindingCwd ?? options.cwd,
1607
1620
  permissions: grant.app.permissions,
1608
1621
  isAuthorized: () => authorized(grant),
1622
+ assertAuthorized: () => {
1623
+ try {
1624
+ grant.projectScope?.assertCurrent();
1625
+ const root = lstatSync(grant.root);
1626
+ if (closed ||
1627
+ grants.get(grant.id) !== grant ||
1628
+ byGuest.get(grant.guestId) !== grant ||
1629
+ grant.revision !== expectedRevision ||
1630
+ grant.expiresAt <= now() ||
1631
+ !grant.app.permissions.includes("storage") ||
1632
+ root.isSymbolicLink() ||
1633
+ !root.isDirectory() ||
1634
+ root.dev + ":" + root.ino !== grant.rootIdentity)
1635
+ throw new PanelBridgeError("REVOKED", "Panel App storage scope changed");
1636
+ }
1637
+ catch (cause) {
1638
+ remove(grant);
1639
+ throw cause;
1640
+ }
1641
+ },
1609
1642
  }, method, params);
1610
1643
  }
1611
1644
  return {
@@ -27,4 +27,4 @@ export declare function applyPanelAppStorageChange(storage: PanelAppStorage, cha
27
27
  };
28
28
  export declare function preparePanelAppStorage(file: string): Promise<void>;
29
29
  export declare function readPanelAppStorage(file: string, quotaBytes: number): Promise<PanelAppStorage>;
30
- export declare function writePanelAppStorage(file: string, storage: PanelAppStorage, quotaBytes: number, beforeCommit?: () => Promise<void>): Promise<void>;
30
+ export declare function writePanelAppStorage(file: string, storage: PanelAppStorage, quotaBytes: number, beforeCommit?: () => Promise<void>, finalCommitFence?: () => void): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { constants } from "node:fs";
2
+ import { constants, renameSync } from "node:fs";
3
3
  import { chmod, lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  /** Matches the existing Desktop namespace, so its remote Web shares saved panel state. */
@@ -161,7 +161,7 @@ export async function readPanelAppStorage(file, quotaBytes) {
161
161
  await handle?.close().catch(() => undefined);
162
162
  }
163
163
  }
164
- export async function writePanelAppStorage(file, storage, quotaBytes, beforeCommit) {
164
+ export async function writePanelAppStorage(file, storage, quotaBytes, beforeCommit, finalCommitFence) {
165
165
  const quota = panelAppStorageQuotaBytes(quotaBytes);
166
166
  const entries = Object.entries(storage);
167
167
  if (entries.length > MAX_PANEL_APP_STORAGE_KEYS) {
@@ -187,7 +187,12 @@ export async function writePanelAppStorage(file, storage, quotaBytes, beforeComm
187
187
  // Recheck both authorization and the target after staging, before publishing.
188
188
  await beforeCommit?.();
189
189
  await preparePanelAppStorage(file);
190
- await rename(temporary, file);
190
+ if (finalCommitFence) {
191
+ finalCommitFence();
192
+ renameSync(temporary, file);
193
+ }
194
+ else
195
+ await rename(temporary, file);
191
196
  if (process.platform !== "win32")
192
197
  await chmod(file, 0o600);
193
198
  }
@@ -22,7 +22,7 @@ import { basename, dirname, extname, join, resolve } from "node:path";
22
22
  import { randomBytes, randomUUID } from "node:crypto";
23
23
  import { WebSocketServer } from "ws";
24
24
  import { SessionManager, SettingsManager } from "@cjhyy/code-shell-core";
25
- import { readOutputJournal, readOutputJournalLegacyBase } from "@cjhyy/code-shell-core/internal";
25
+ import { SessionOutputCoverageStore, readOutputJournal, readOutputJournalLegacyBase, } from "@cjhyy/code-shell-core/internal";
26
26
  import { AccessPasscode } from "../mobile-remote/access-passcode.js";
27
27
  import { resolveSafe } from "../mobile-remote/mobile-static.js";
28
28
  import { contentTypeFor } from "../static-files.js";
@@ -37,6 +37,7 @@ import { createHubMcpConfiguration } from "../hub/mcp-configuration.js";
37
37
  import { createLinkHttp } from "../links/http.js";
38
38
  import { createPanelHttp } from "../panels/http.js";
39
39
  import { createHubPanelBinding } from "../panels/hub-binding.js";
40
+ import { capturePanelProjectScope } from "../panels/project-scope.js";
40
41
  import { createHubSessions, hubSessionPreview, readHubSessionState, readHubTranscript, } from "../hub/session-management.js";
41
42
  import { createHubFiles } from "../hub/files.js";
42
43
  import { createSessionHttpResponses } from "../session-http-responses.js";
@@ -123,7 +124,7 @@ export async function startHeadlessServer(opts) {
123
124
  // Independent of response routing: leaving a tab does not stop its worker run.
124
125
  const runningSessions = new Map();
125
126
  const runOwners = new Map();
126
- const runReplay = new HubRunReplay();
127
+ const runReplay = new HubRunReplay(undefined, new SessionOutputCoverageStore(() => sessionRootDir));
127
128
  const runInputs = new Map();
128
129
  let preparingRuns = 0;
129
130
  let configurationChanging = false;
@@ -146,13 +147,21 @@ export async function startHeadlessServer(opts) {
146
147
  broadcast(JSON.stringify({ jsonrpc: "2.0", method, params }));
147
148
  };
148
149
  const publishStream = (sessionId, event) => {
149
- const cursor = runReplay.append(sessionId, event);
150
+ let cursor;
151
+ try {
152
+ cursor = runReplay.append(sessionId, event);
153
+ }
154
+ catch {
155
+ log("stream.coverage_unavailable", { sessionId });
156
+ return false;
157
+ }
150
158
  notify("agent/streamEvent", {
151
159
  sessionId,
152
160
  event,
153
161
  hubSequence: cursor.sequence,
154
162
  hubEpoch: cursor.epoch,
155
163
  });
164
+ return true;
156
165
  };
157
166
  const leases = new ApprovalLeases((lease) => notify("serve/approvalLease", lease));
158
167
  const authReaper = hubAuth
@@ -235,6 +244,7 @@ export async function startHeadlessServer(opts) {
235
244
  const params = message.params;
236
245
  if (typeof params?.sessionId === "string" && params.event !== undefined) {
237
246
  let event = params.event;
247
+ let deliveredInput;
238
248
  const value = event;
239
249
  if (value?.type === "session_user_message" && typeof value.clientMessageId === "string") {
240
250
  const input = [...runInputs.values()].find((candidate) => candidate.sessionId === params.sessionId &&
@@ -242,11 +252,17 @@ export async function startHeadlessServer(opts) {
242
252
  if (input) {
243
253
  if (input.echoed)
244
254
  return;
245
- input.echoed = true;
246
- event = input.event;
255
+ deliveredInput = input;
256
+ event = {
257
+ ...input.event,
258
+ ...(typeof value.outputCursor === "string"
259
+ ? { outputCursor: value.outputCursor }
260
+ : {}),
261
+ };
247
262
  }
248
263
  }
249
- publishStream(params.sessionId, event);
264
+ if (publishStream(params.sessionId, event) && deliveredInput)
265
+ deliveredInput.echoed = true;
250
266
  return;
251
267
  }
252
268
  }
@@ -255,8 +271,7 @@ export async function startHeadlessServer(opts) {
255
271
  const requestId = String(params?.requestId);
256
272
  const input = runInputs.get(requestId);
257
273
  if (input && !input.echoed) {
258
- input.echoed = true;
259
- publishStream(input.sessionId, input.event);
274
+ input.echoed = publishStream(input.sessionId, input.event);
260
275
  }
261
276
  const route = pendingWorkerResponses.get(requestId);
262
277
  if (route)
@@ -462,6 +477,17 @@ export async function startHeadlessServer(opts) {
462
477
  ...panelBinding,
463
478
  dataDir: opts.dataDir,
464
479
  host: "hub",
480
+ projectScope: () => {
481
+ if (!environment || !panelBinding)
482
+ throw new Error("Hub project identity is unavailable");
483
+ return capturePanelProjectScope({
484
+ environmentDir: opts.dataDir,
485
+ environmentId: environment.id,
486
+ projectPath: panelBinding.bindingCwd,
487
+ cwd: workspaceCwd,
488
+ assertAuthority: panelBinding.assertBinding,
489
+ });
490
+ },
465
491
  automations: {
466
492
  conditionalMutations: true,
467
493
  call: (scope, method, params) => {
@@ -506,7 +532,11 @@ export async function startHeadlessServer(opts) {
506
532
  dataDir: opts.dataDir,
507
533
  isAuthorized: configurationOptions.isAuthorized,
508
534
  isRunning,
509
- onChanged: (sessionId) => notify("serve/sessionsChanged", { sessionId }),
535
+ onChanged: (sessionId) => {
536
+ if (!existsSync(join(sessionRootDir, sessionId, "state.json")))
537
+ runReplay.forget(sessionId);
538
+ notify("serve/sessionsChanged", { sessionId });
539
+ },
510
540
  })
511
541
  : undefined;
512
542
  const files = hubAuth
@@ -1135,7 +1165,9 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd, isRunnin
1135
1165
  result: { type: "sessions", data: sessions },
1136
1166
  });
1137
1167
  }
1138
- if (queryType !== "session_detail" && queryType !== "output_journal")
1168
+ if (queryType !== "session_detail" &&
1169
+ queryType !== "output_journal" &&
1170
+ queryType !== "output_coverage")
1139
1171
  return undefined;
1140
1172
  const sessionId = message.params?.sessionId;
1141
1173
  if (typeof sessionId !== "string" || sessionId.length === 0) {
@@ -1150,6 +1182,27 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd, isRunnin
1150
1182
  if (resolve(state.cwd) !== workspaceCwd) {
1151
1183
  return hostQueryError(message.id, -32001, "Session not found in this workspace");
1152
1184
  }
1185
+ if (queryType === "output_coverage") {
1186
+ // Completed RAM entries can be retired once their proof belongs to disk.
1187
+ // Freeze the real current journal head, so a lost live notification or
1188
+ // run finish during pagination cannot hide a later durable prefix.
1189
+ const head = readOutputJournal(sessionManager.getStorageDir(), sessionId, { maxFrames: 1 });
1190
+ const coverage = runReplay?.outputCoverage(sessionId);
1191
+ return JSON.stringify({
1192
+ jsonrpc: "2.0",
1193
+ id: message.id,
1194
+ result: {
1195
+ type: "output_coverage",
1196
+ data: {
1197
+ ...coverage,
1198
+ unpaired: !!coverage?.unpaired ||
1199
+ (head.status !== "ok" &&
1200
+ (head.status !== "unavailable" || !!coverage?.journalIdentity)),
1201
+ ...(head.through ? { outputCursor: head.through } : {}),
1202
+ },
1203
+ },
1204
+ });
1205
+ }
1153
1206
  if (queryType === "output_journal") {
1154
1207
  const data = readOutputJournal(sessionManager.getStorageDir(), sessionId, {
1155
1208
  after: message.params?.after,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.32",
3
+ "version": "0.9.33",
4
4
  "description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,13 +60,13 @@
60
60
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
61
61
  },
62
62
  "dependencies": {
63
- "@cjhyy/code-shell-capability-coding": "0.9.32",
64
- "@cjhyy/code-shell-core": "0.9.32",
65
- "@cjhyy/code-shell-web": "0.9.32",
63
+ "@cjhyy/code-shell-capability-coding": "0.9.33",
64
+ "@cjhyy/code-shell-core": "0.9.33",
65
+ "@cjhyy/code-shell-web": "0.9.33",
66
66
  "@modelcontextprotocol/sdk": "^1.31.0",
67
67
  "@types/ws": "^8.18.1",
68
68
  "ws": "^8.21.0",
69
- "@cjhyy/code-shell-link": "0.9.32"
69
+ "@cjhyy/code-shell-link": "0.9.33"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">=20.10"