@cjhyy/code-shell-server 0.9.44 → 0.9.47

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 (94) hide show
  1. package/dist/attachment-service.d.ts +2 -0
  2. package/dist/attachment-service.js +40 -27
  3. package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
  4. package/dist/bin/code-shell-link-cleanup.js +3 -0
  5. package/dist/desktop-web/http-api.d.ts +3 -0
  6. package/dist/desktop-web/http-api.js +16 -1
  7. package/dist/hub/configuration.d.ts +6 -3
  8. package/dist/hub/configuration.js +19 -6
  9. package/dist/hub/mcp-probe.d.ts +4 -24
  10. package/dist/hub/mcp-probe.js +18 -389
  11. package/dist/hub/model-probe.d.ts +2 -20
  12. package/dist/hub/model-probe.js +2 -193
  13. package/dist/hub/run-replay.d.ts +7 -1
  14. package/dist/hub/run-replay.js +10 -2
  15. package/dist/hub/session-management.js +18 -53
  16. package/dist/hub/skills-management.js +6 -8
  17. package/dist/hub/uploads.d.ts +10 -0
  18. package/dist/hub/uploads.js +107 -15
  19. package/dist/index.desktop-web.d.ts +1 -0
  20. package/dist/index.desktop-web.js +1 -0
  21. package/dist/index.links.d.ts +3 -0
  22. package/dist/index.links.js +2 -0
  23. package/dist/index.panels.d.ts +1 -0
  24. package/dist/index.panels.js +1 -0
  25. package/dist/index.serve.d.ts +1 -0
  26. package/dist/index.serve.js +1 -0
  27. package/dist/index.storage.d.ts +1 -0
  28. package/dist/index.storage.js +1 -0
  29. package/dist/links/catalog.js +2 -0
  30. package/dist/links/diagnostics.d.ts +60 -0
  31. package/dist/links/diagnostics.js +184 -0
  32. package/dist/links/http.d.ts +10 -8
  33. package/dist/links/http.js +144 -125
  34. package/dist/links/legacy-cleanup-cli.d.ts +3 -0
  35. package/dist/links/legacy-cleanup-cli.js +64 -0
  36. package/dist/links/providers/defaults.d.ts +5 -0
  37. package/dist/links/providers/defaults.js +782 -0
  38. package/dist/links/providers/index.d.ts +7 -0
  39. package/dist/links/providers/index.js +22 -0
  40. package/dist/links/remote-catalog.d.ts +10 -3
  41. package/dist/links/remote-catalog.js +76 -29
  42. package/dist/links/runtime/operation.d.ts +9 -0
  43. package/dist/links/runtime/operation.js +16 -0
  44. package/dist/links/service.d.ts +18 -2
  45. package/dist/links/service.js +593 -62
  46. package/dist/links/types.d.ts +21 -1
  47. package/dist/mobile-remote/codex-parse.d.ts +3 -1
  48. package/dist/mobile-remote/codex-parse.js +45 -11
  49. package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
  50. package/dist/mobile-remote/codex-room-agent.js +30 -32
  51. package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
  52. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  53. package/dist/mobile-remote/mobile-client-event-validator.js +21 -0
  54. package/dist/mobile-remote/mobile-history.d.ts +10 -1
  55. package/dist/mobile-remote/mobile-history.js +24 -16
  56. package/dist/mobile-remote/remote-host-manager.d.ts +10 -0
  57. package/dist/mobile-remote/remote-host-manager.js +50 -13
  58. package/dist/mobile-remote/resident-agent.js +38 -22
  59. package/dist/mobile-remote/room-manager.d.ts +23 -13
  60. package/dist/mobile-remote/room-manager.js +106 -22
  61. package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
  62. package/dist/mobile-remote/room-model-tracing.js +196 -0
  63. package/dist/mobile-remote/room-process-stop.d.ts +3 -0
  64. package/dist/mobile-remote/room-process-stop.js +28 -0
  65. package/dist/model-trace-history.d.ts +29 -0
  66. package/dist/model-trace-history.js +198 -0
  67. package/dist/model-trace-http.d.ts +14 -0
  68. package/dist/model-trace-http.js +105 -0
  69. package/dist/panels/agent-task-host.js +1 -0
  70. package/dist/panels/agent-task-service.d.ts +2 -1
  71. package/dist/panels/agent-task-service.js +10 -3
  72. package/dist/panels/directory-bookmarks.js +62 -29
  73. package/dist/panels/execution-gate.d.ts +19 -1
  74. package/dist/panels/execution-gate.js +33 -5
  75. package/dist/panels/http.d.ts +1 -0
  76. package/dist/panels/hub-automations.js +1 -0
  77. package/dist/panels/hub-binding.js +5 -5
  78. package/dist/panels/model-trace-channel.d.ts +21 -0
  79. package/dist/panels/model-trace-channel.js +110 -0
  80. package/dist/panels/model-traces.d.ts +39 -0
  81. package/dist/panels/model-traces.js +222 -0
  82. package/dist/panels/process-service.d.ts +12 -2
  83. package/dist/panels/process-service.js +49 -6
  84. package/dist/panels/resources/uploads.js +15 -6
  85. package/dist/panels/runtime.d.ts +1 -0
  86. package/dist/panels/runtime.js +66 -8
  87. package/dist/panels/task-cookies.js +8 -16
  88. package/dist/panels/tool-jobs.d.ts +2 -0
  89. package/dist/panels/tool-jobs.js +28 -3
  90. package/dist/serve/cli.js +8 -1
  91. package/dist/serve/headless-server.js +68 -14
  92. package/dist/sessions-service.js +17 -27
  93. package/dist/worker-bridge-core.js +11 -0
  94. package/package.json +8 -7
@@ -0,0 +1,28 @@
1
+ /** Retire an unusable room transport without leaving an unresponsive CLI alive. */
2
+ export function stopRoomProcess(child) {
3
+ if (child.exitCode !== null || child.signalCode !== null)
4
+ return;
5
+ const signal = (value) => {
6
+ if (child.pid && child.pid > 1 && process.platform !== "win32") {
7
+ try {
8
+ process.kill(-child.pid, value);
9
+ return;
10
+ }
11
+ catch {
12
+ /* A failed spawn or an exited group falls back to the exact child. */
13
+ }
14
+ }
15
+ try {
16
+ child.kill(value);
17
+ }
18
+ catch {
19
+ /* already gone */
20
+ }
21
+ };
22
+ signal("SIGTERM");
23
+ if (!child.pid)
24
+ return;
25
+ const timer = setTimeout(() => signal("SIGKILL"), 1_000);
26
+ timer.unref();
27
+ child.once("exit", () => clearTimeout(timer));
28
+ }
@@ -0,0 +1,29 @@
1
+ import { ModelTraceStore, type ModelTraceConfig, type ModelTraceConfigPatch, type ModelTraceEvent, type ModelTraceListQuery, type ModelTracePage, type ModelTraceSessionPage, type ModelTraceRecord } from "@cjhyy/code-shell-core/internal";
2
+ export type ModelTraceDetail = {
3
+ record: ModelTraceRecord;
4
+ events: ModelTraceEvent[];
5
+ };
6
+ export type ModelTraceReader = Pick<ModelTraceStore, "list" | "sessions" | "get" | "events" | "getConfig" | "setConfig">;
7
+ export declare class ModelTraceRequestError extends Error {
8
+ readonly status: number;
9
+ constructor(status: number, message: string);
10
+ }
11
+ /** Never forward renderer/browser-owned filesystem paths or unknown scan options. */
12
+ export declare function validateModelTraceQuery(input?: unknown): ModelTraceListQuery;
13
+ export declare function validateModelTraceId(input: unknown): string;
14
+ export declare function validateModelTraceConfigPatch(input: unknown): ModelTraceConfigPatch;
15
+ /** Host fixes storageDir once; no query or request can select a different store. */
16
+ export declare function createModelTraceHistory(options: {
17
+ storageDir: string;
18
+ /** Set only by the authenticated Host, never by list/get arguments. */
19
+ workspacePath?: string;
20
+ /** Additional scopes fixed and authorized by the Host, never by list/get input. */
21
+ workspaceScopes?: ModelTraceListQuery["workspaceScopes"];
22
+ store?: ModelTraceReader;
23
+ }): {
24
+ list(query?: unknown): ModelTracePage;
25
+ sessions(query?: unknown): ModelTraceSessionPage;
26
+ get(id: unknown): ModelTraceDetail | null;
27
+ getConfig(): ModelTraceConfig;
28
+ setConfig(patch: unknown): ModelTraceConfig;
29
+ };
@@ -0,0 +1,198 @@
1
+ import { ModelTraceStore, isModelTraceCallType, } from "@cjhyy/code-shell-core/internal";
2
+ import { resolve } from "node:path";
3
+ const QUERY_FIELDS = new Set([
4
+ "limit",
5
+ "cursor",
6
+ "search",
7
+ "traceId",
8
+ "sessionId",
9
+ "withoutSession",
10
+ "model",
11
+ "provider",
12
+ "purpose",
13
+ "callType",
14
+ "source",
15
+ "status",
16
+ "since",
17
+ "until",
18
+ ]);
19
+ const CONFIG_FIELDS = new Set(["enabled", "captureContent", "includeEphemeral", "retentionDays"]);
20
+ export class ModelTraceRequestError extends Error {
21
+ status;
22
+ constructor(status, message) {
23
+ super(message);
24
+ this.status = status;
25
+ }
26
+ }
27
+ function record(value, label) {
28
+ if (!value || typeof value !== "object" || Array.isArray(value))
29
+ throw new ModelTraceRequestError(400, `Invalid model trace ${label}`);
30
+ return value;
31
+ }
32
+ /** Never forward renderer/browser-owned filesystem paths or unknown scan options. */
33
+ export function validateModelTraceQuery(input) {
34
+ if (input === undefined)
35
+ return {};
36
+ const query = record(input, "query");
37
+ const result = {};
38
+ for (const [key, value] of Object.entries(query)) {
39
+ if (!QUERY_FIELDS.has(key))
40
+ throw new ModelTraceRequestError(400, `Unknown model trace query field: ${key}`);
41
+ if (value === undefined)
42
+ continue;
43
+ if (key === "callType") {
44
+ if (!isModelTraceCallType(value))
45
+ throw new ModelTraceRequestError(400, "Invalid model trace callType");
46
+ }
47
+ else if (key === "withoutSession") {
48
+ if (typeof value !== "boolean")
49
+ throw new ModelTraceRequestError(400, "Invalid model trace withoutSession");
50
+ }
51
+ else if (key === "limit") {
52
+ if (!Number.isSafeInteger(value) || value < 1 || value > 200)
53
+ throw new ModelTraceRequestError(400, "Model trace limit must be between 1 and 200");
54
+ }
55
+ else if (key === "since" || key === "until") {
56
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
57
+ throw new ModelTraceRequestError(400, `Invalid model trace ${key}`);
58
+ }
59
+ else {
60
+ const max = key === "search" || key === "cursor" ? 2048 : 512;
61
+ if (typeof value !== "string" || value.length > max || value.includes("\0"))
62
+ throw new ModelTraceRequestError(400, `Invalid model trace ${key}`);
63
+ if (key === "status" && !["running", "completed", "failed", "cancelled"].includes(value))
64
+ throw new ModelTraceRequestError(400, "Invalid model trace status");
65
+ }
66
+ result[key] = value;
67
+ }
68
+ if (typeof result.since === "number" &&
69
+ typeof result.until === "number" &&
70
+ result.since > result.until)
71
+ throw new ModelTraceRequestError(400, "Model trace time range is reversed");
72
+ return result;
73
+ }
74
+ export function validateModelTraceId(input) {
75
+ if (typeof input !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(input))
76
+ throw new ModelTraceRequestError(400, "Invalid model trace id");
77
+ return input;
78
+ }
79
+ export function validateModelTraceConfigPatch(input) {
80
+ const patch = record(input, "configuration");
81
+ for (const [key, value] of Object.entries(patch)) {
82
+ if (!CONFIG_FIELDS.has(key))
83
+ throw new ModelTraceRequestError(400, `Unknown model trace configuration field: ${key}`);
84
+ if ((key === "enabled" || key === "captureContent" || key === "includeEphemeral") &&
85
+ typeof value !== "boolean")
86
+ throw new ModelTraceRequestError(400, `Invalid model trace ${key}`);
87
+ if (key === "retentionDays" &&
88
+ (!Number.isSafeInteger(value) || value < 1 || value > 365))
89
+ throw new ModelTraceRequestError(400, "Model trace retention must be between 1 and 365 days");
90
+ }
91
+ return { ...patch };
92
+ }
93
+ /** Host fixes storageDir once; no query or request can select a different store. */
94
+ export function createModelTraceHistory(options) {
95
+ const store = options.store ?? new ModelTraceStore({ storageDir: options.storageDir });
96
+ const workspacePath = options.workspacePath === undefined ? undefined : resolve(options.workspacePath);
97
+ if (options.workspaceScopes !== undefined &&
98
+ (!Array.isArray(options.workspaceScopes) ||
99
+ options.workspaceScopes.length + (workspacePath === undefined ? 0 : 1) > 8 ||
100
+ options.workspaceScopes.some((scope) => !scope ||
101
+ typeof scope !== "object" ||
102
+ Array.isArray(scope) ||
103
+ Object.keys(scope).some((field) => !["workspacePath", "sourcePrefix"].includes(field)) ||
104
+ typeof scope.workspacePath !== "string" ||
105
+ !scope.workspacePath ||
106
+ scope.workspacePath.length > 4096 ||
107
+ scope.workspacePath.includes("\0") ||
108
+ (scope.sourcePrefix !== undefined &&
109
+ (typeof scope.sourcePrefix !== "string" ||
110
+ !scope.sourcePrefix ||
111
+ scope.sourcePrefix.length > 256 ||
112
+ scope.sourcePrefix.includes("\0"))))))
113
+ throw new ModelTraceRequestError(500, "Invalid Host model trace scopes");
114
+ const scopes = options.workspaceScopes
115
+ ? [
116
+ ...(workspacePath === undefined ? [] : [{ workspacePath }]),
117
+ ...options.workspaceScopes.map((scope) => ({
118
+ ...scope,
119
+ workspacePath: resolve(scope.workspacePath),
120
+ })),
121
+ ]
122
+ : undefined;
123
+ const matchesPath = (entry, path) => entry.workspacePath !== undefined && resolve(entry.workspacePath) === path;
124
+ const unrestricted = (entry) => scopes
125
+ ? scopes.some((scope) => scope.sourcePrefix === undefined && matchesPath(entry, scope.workspacePath))
126
+ : workspacePath === undefined || matchesPath(entry, workspacePath);
127
+ const visible = (entry) => unrestricted(entry) ||
128
+ !!scopes?.some((scope) => matchesPath(entry, scope.workspacePath) &&
129
+ typeof entry.source === "string" &&
130
+ scope.sourcePrefix !== undefined &&
131
+ entry.source.startsWith(scope.sourcePrefix));
132
+ const visibleEvents = (entry) => {
133
+ const events = store.events(entry.traceId);
134
+ const accepted = new Set(events.filter(unrestricted).map((event) => event.id));
135
+ // Events do not carry a source. A prefix-limited related scope may only
136
+ // expose the exact selected call's descendants, never another root chat's
137
+ // events merely because they share a trace identifier.
138
+ const children = new Map();
139
+ if (entry.workspacePath !== undefined)
140
+ for (const event of events)
141
+ if (event.parentId && matchesPath(event, resolve(entry.workspacePath))) {
142
+ const siblings = children.get(event.parentId) ?? [];
143
+ siblings.push(event);
144
+ children.set(event.parentId, siblings);
145
+ }
146
+ const queue = [entry.id];
147
+ const visited = new Set(queue);
148
+ for (let index = 0; index < queue.length; index++)
149
+ for (const event of children.get(queue[index]) ?? [])
150
+ if (!visited.has(event.id)) {
151
+ visited.add(event.id);
152
+ accepted.add(event.id);
153
+ queue.push(event.id);
154
+ }
155
+ return events.filter((event) => accepted.has(event.id));
156
+ };
157
+ return {
158
+ list(query) {
159
+ const validated = validateModelTraceQuery(query);
160
+ return store.list({
161
+ ...validated,
162
+ ...(scopes
163
+ ? { workspaceScopes: scopes }
164
+ : workspacePath === undefined
165
+ ? {}
166
+ : { workspacePath }),
167
+ });
168
+ },
169
+ sessions(query) {
170
+ const validated = validateModelTraceQuery(query);
171
+ try {
172
+ return store.sessions({
173
+ ...validated,
174
+ ...(scopes
175
+ ? { workspaceScopes: scopes }
176
+ : workspacePath === undefined
177
+ ? {}
178
+ : { workspacePath }),
179
+ });
180
+ }
181
+ catch (error) {
182
+ if (error instanceof Error && error.message === "Invalid model trace session cursor")
183
+ throw new ModelTraceRequestError(400, error.message);
184
+ throw error;
185
+ }
186
+ },
187
+ get(id) {
188
+ const entry = store.get(validateModelTraceId(id));
189
+ return entry && visible(entry) ? { record: entry, events: visibleEvents(entry) } : null;
190
+ },
191
+ getConfig() {
192
+ return store.getConfig();
193
+ },
194
+ setConfig(patch) {
195
+ return store.setConfig(validateModelTraceConfigPatch(patch));
196
+ },
197
+ };
198
+ }
@@ -0,0 +1,14 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { ModelTraceListQuery } from "@cjhyy/code-shell-core/internal";
3
+ import { type ModelTraceReader } from "./model-trace-history.js";
4
+ /** Authenticated read-only facade over one Host-owned store and one fixed workspace. */
5
+ export declare function createModelTraceHttp(options: {
6
+ storageDir: string;
7
+ workspacePath: string;
8
+ isAuthorized: (request: IncomingMessage) => boolean | Promise<boolean>;
9
+ /** Recomputed for each authenticated request and immediately before responding. */
10
+ resolveWorkspaceScopes?: (request: IncomingMessage) => ModelTraceListQuery["workspaceScopes"] | Promise<ModelTraceListQuery["workspaceScopes"]>;
11
+ store?: ModelTraceReader;
12
+ }): {
13
+ handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
14
+ };
@@ -0,0 +1,105 @@
1
+ import { resolve } from "node:path";
2
+ import { createModelTraceHistory, ModelTraceRequestError, } from "./model-trace-history.js";
3
+ const ROOT = "/api/v1/model-traces";
4
+ function json(response, status, body) {
5
+ if (response.destroyed || response.writableEnded)
6
+ return;
7
+ response.writeHead(status, {
8
+ "content-type": "application/json; charset=utf-8",
9
+ "cache-control": "no-store",
10
+ "x-content-type-options": "nosniff",
11
+ });
12
+ response.end(JSON.stringify(body));
13
+ }
14
+ function queryFromUrl(url) {
15
+ const query = {};
16
+ for (const key of new Set(url.searchParams.keys())) {
17
+ if (key === "workspace")
18
+ continue; // Resolved and authorized by the outer Host route.
19
+ const values = url.searchParams.getAll(key);
20
+ if (values.length !== 1)
21
+ throw new ModelTraceRequestError(400, "Duplicate model trace query field");
22
+ const value = values[0];
23
+ if (key === "withoutSession") {
24
+ if (value !== "true" && value !== "false")
25
+ throw new ModelTraceRequestError(400, "Invalid model trace withoutSession");
26
+ query[key] = value === "true";
27
+ }
28
+ else if (["limit", "since", "until"].includes(key)) {
29
+ if (!value.trim())
30
+ throw new ModelTraceRequestError(400, "Invalid numeric model trace query");
31
+ query[key] = Number(value);
32
+ }
33
+ else
34
+ query[key] = value;
35
+ }
36
+ return query;
37
+ }
38
+ /** Authenticated read-only facade over one Host-owned store and one fixed workspace. */
39
+ export function createModelTraceHttp(options) {
40
+ const resolveScopes = async (request) => {
41
+ const scopes = await options.resolveWorkspaceScopes?.(request);
42
+ return scopes?.map((scope) => ({ ...scope, workspacePath: resolve(scope.workspacePath) }));
43
+ };
44
+ const fingerprint = (scopes) => JSON.stringify(scopes?.map((scope) => [scope.workspacePath, scope.sourcePrefix ?? null]).sort());
45
+ return {
46
+ async handle(request, response) {
47
+ const url = new URL(request.url ?? "/", "http://localhost");
48
+ if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`))
49
+ return false;
50
+ try {
51
+ if (!(await options.isAuthorized(request)))
52
+ throw new ModelTraceRequestError(401, "Model history authorization expired");
53
+ if (request.method !== "GET") {
54
+ response.setHeader("allow", "GET");
55
+ throw new ModelTraceRequestError(405, "Model history is read-only on remote clients");
56
+ }
57
+ const workspaceScopes = await resolveScopes(request);
58
+ const history = createModelTraceHistory({ ...options, workspaceScopes });
59
+ let result;
60
+ if (url.pathname === ROOT)
61
+ result = history.list(queryFromUrl(url));
62
+ else if (url.pathname === `${ROOT}/sessions`)
63
+ result = history.sessions(queryFromUrl(url));
64
+ else if (url.pathname === `${ROOT}/config`) {
65
+ if (Object.keys(queryFromUrl(url)).length)
66
+ throw new ModelTraceRequestError(400, "Configuration queries are not supported");
67
+ result = history.getConfig();
68
+ }
69
+ else {
70
+ const match = new RegExp(`^${ROOT}/([^/]+)$`).exec(url.pathname);
71
+ if (!match)
72
+ throw new ModelTraceRequestError(404, "Model record not found");
73
+ if (Object.keys(queryFromUrl(url)).length)
74
+ throw new ModelTraceRequestError(400, "Record queries are not supported");
75
+ let id;
76
+ try {
77
+ id = decodeURIComponent(match[1]);
78
+ }
79
+ catch {
80
+ throw new ModelTraceRequestError(400, "Invalid model trace id");
81
+ }
82
+ result = history.get(id);
83
+ if (!result)
84
+ throw new ModelTraceRequestError(404, "Model record not found");
85
+ }
86
+ // Authentication may have been revoked during asynchronous Host validation.
87
+ if (!(await options.isAuthorized(request)))
88
+ throw new ModelTraceRequestError(401, "Model history authorization expired");
89
+ if (fingerprint(await resolveScopes(request)) !== fingerprint(workspaceScopes))
90
+ throw new ModelTraceRequestError(401, "Model history workspace authorization changed");
91
+ if (!(await options.isAuthorized(request)))
92
+ throw new ModelTraceRequestError(401, "Model history authorization expired");
93
+ json(response, 200, result);
94
+ }
95
+ catch (error) {
96
+ json(response, error instanceof ModelTraceRequestError ? error.status : 500, {
97
+ error: error instanceof ModelTraceRequestError
98
+ ? error.message
99
+ : "Model history is unavailable",
100
+ });
101
+ }
102
+ return true;
103
+ },
104
+ };
105
+ }
@@ -341,6 +341,7 @@ export function createPanelAgentTaskHost(options = {}) {
341
341
  cwd: scope.cwd,
342
342
  bucket: owner.bucket,
343
343
  task: input.prompt,
344
+ modelTraceSource: `panel:${scope.appId}`,
344
345
  displayText: input.label,
345
346
  clientMessageId: `panel-task:${scope.appId}:${input.sessionId}`,
346
347
  behaviorMode: "isolatedTask",
@@ -96,7 +96,8 @@ export declare class PanelAppAgentTaskService {
96
96
  get(owner: PanelAgentTaskOwner, id: unknown): PanelAgentTaskView;
97
97
  cancel(owner: PanelAgentTaskOwner, id: unknown): Promise<PanelAgentTaskView>;
98
98
  rebind(owner: PanelAgentTaskOwner): void;
99
- cancelApp(appId: string): void;
99
+ /** `projects` limits cancellation to tasks bound to those (canonical) projects. */
100
+ cancelApp(appId: string, projects?: ReadonlySet<string>): void;
100
101
  private execute;
101
102
  private scopedTask;
102
103
  private emit;
@@ -1,4 +1,4 @@
1
- import { panelExecutionGate } from "./execution-gate.js";
1
+ import { panelExecutionGate, panelExecutionProject } from "./execution-gate.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  const MAX_TASKS = 200;
4
4
  const MAX_PROMPT_CHARS = 20_000;
@@ -131,7 +131,11 @@ export class PanelAppAgentTaskService {
131
131
  cancelRequested: false,
132
132
  activity: [],
133
133
  };
134
- const release = panelExecutionGate.enter(owner);
134
+ const release = panelExecutionGate.enter({
135
+ appId: owner.appId,
136
+ projectPath: owner.projectPath,
137
+ holder: { kind: "agent-task", guestId: owner.guestId },
138
+ });
135
139
  this.tasks.set(task.id, task);
136
140
  this.trimHistory();
137
141
  this.emit(task);
@@ -174,10 +178,13 @@ export class PanelAppAgentTaskService {
174
178
  this.runtime.rebind(task.sessionId, task.owner);
175
179
  }
176
180
  }
177
- cancelApp(appId) {
181
+ /** `projects` limits cancellation to tasks bound to those (canonical) projects. */
182
+ cancelApp(appId, projects) {
178
183
  for (const task of this.tasks.values()) {
179
184
  if (task.owner.appId !== appId || TERMINAL_STATUSES.has(task.status))
180
185
  continue;
186
+ if (projects && !projects.has(panelExecutionProject(task.owner.projectPath)))
187
+ continue;
181
188
  task.cancelRequested = true;
182
189
  task.status = "cancelling";
183
190
  task.updatedAt = this.now();
@@ -12,14 +12,14 @@ export function createPanelDirectoryProjectScope(workspacePath, expectedProjectP
12
12
  const binding = createHubPanelBinding(workspacePath);
13
13
  binding.assertBinding();
14
14
  const projectPath = realpathSync(expectedProjectPath);
15
- const identity = lstatSync(projectPath);
15
+ const identity = lstatSync(projectPath, { bigint: true });
16
16
  if (!identity.isDirectory() ||
17
17
  identity.isSymbolicLink() ||
18
18
  realpathSync(binding.bindingCwd) !== projectPath)
19
19
  throw new Error("Panel directory project scope is unavailable");
20
20
  const assertCurrent = () => {
21
21
  binding.assertBinding();
22
- const current = lstatSync(projectPath);
22
+ const current = lstatSync(projectPath, { bigint: true });
23
23
  if (!current.isDirectory() ||
24
24
  current.isSymbolicLink() ||
25
25
  current.dev !== identity.dev ||
@@ -65,11 +65,14 @@ export function hubPanelDirectoryBookmarks(dataDir) {
65
65
  const MAX_RECORDS = 256;
66
66
  const MAX_BYTES = 256 * 1024;
67
67
  const ID = /^[a-f0-9-]{36}$/i;
68
- function valid(record) {
68
+ function parseBookmark(record, version) {
69
+ const invalid = () => {
70
+ throw new Error("Invalid Panel directory bookmarks");
71
+ };
69
72
  if (!record || typeof record !== "object" || Array.isArray(record))
70
- return false;
73
+ return invalid();
71
74
  const item = record;
72
- return (typeof item.id === "string" &&
75
+ if (!(typeof item.id === "string" &&
73
76
  ID.test(item.id) &&
74
77
  typeof item.appId === "string" &&
75
78
  /^[a-z][a-z0-9-]{0,63}$/.test(item.appId) &&
@@ -79,9 +82,36 @@ function valid(record) {
79
82
  isAbsolute(item.path) &&
80
83
  item.path.length <= 32_768 &&
81
84
  item.projectPath.length <= 32_768 &&
82
- Number.isSafeInteger(item.dev) &&
83
- Number.isSafeInteger(item.ino) &&
84
- Number.isSafeInteger(item.savedAt));
85
+ Number.isSafeInteger(item.savedAt)))
86
+ return invalid();
87
+ let dev, ino;
88
+ if (version === 1) {
89
+ const legacy = record;
90
+ if (typeof legacy.dev !== "number" ||
91
+ !Number.isInteger(legacy.dev) ||
92
+ legacy.dev < 0 ||
93
+ typeof legacy.ino !== "number" ||
94
+ !Number.isInteger(legacy.ino) ||
95
+ legacy.ino < 0)
96
+ return invalid();
97
+ // Never recover an authorization by rounding a file ID. Retain a tombstone
98
+ // so this UUID cannot fall back to a different grant in the legacy file.
99
+ if (!Number.isSafeInteger(legacy.dev) || !Number.isSafeInteger(legacy.ino)) {
100
+ dev = ino = null;
101
+ }
102
+ else {
103
+ dev = String(legacy.dev);
104
+ ino = String(legacy.ino);
105
+ }
106
+ }
107
+ else {
108
+ const exact = (value) => typeof value === "string" && /^(0|[1-9][0-9]{0,19})$/.test(value);
109
+ if (!(item.dev === null && item.ino === null) && !(exact(item.dev) && exact(item.ino)))
110
+ return invalid();
111
+ dev = item.dev;
112
+ ino = item.ino;
113
+ }
114
+ return { ...item, dev, ino };
85
115
  }
86
116
  function read(file) {
87
117
  let fd;
@@ -97,12 +127,11 @@ function read(file) {
97
127
  if (!document || typeof document !== "object" || Array.isArray(document))
98
128
  throw new Error("Invalid Panel directory bookmarks");
99
129
  const value = document;
100
- if (value.version !== 1 ||
130
+ if ((value.version !== 1 && value.version !== 2) ||
101
131
  !Array.isArray(value.bookmarks) ||
102
- value.bookmarks.length > MAX_RECORDS ||
103
- !value.bookmarks.every(valid))
132
+ value.bookmarks.length > MAX_RECORDS)
104
133
  throw new Error("Invalid Panel directory bookmarks");
105
- return value.bookmarks;
134
+ return value.bookmarks.map((record) => parseBookmark(record, value.version));
106
135
  }
107
136
  catch (error) {
108
137
  if (error.code === "ENOENT")
@@ -129,7 +158,7 @@ function write(file, bookmarks) {
129
158
  if (error.code !== "ENOENT")
130
159
  throw error;
131
160
  }
132
- const serialized = JSON.stringify({ version: 1, bookmarks });
161
+ const serialized = JSON.stringify({ version: 2, bookmarks });
133
162
  if (Buffer.byteLength(serialized) > MAX_BYTES)
134
163
  throw new Error("Panel directory bookmarks are full");
135
164
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
@@ -154,7 +183,7 @@ export class PanelAppDirectoryBookmarks {
154
183
  remember(appId, projectPath, path) {
155
184
  if (!/^[a-z][a-z0-9-]{0,63}$/.test(appId) || !isAbsolute(projectPath) || !isAbsolute(path))
156
185
  throw new Error("Invalid Panel directory bookmark scope");
157
- const info = lstatSync(path);
186
+ const info = lstatSync(path, { bigint: true });
158
187
  if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path)
159
188
  throw new Error("Selected directory changed; choose it again");
160
189
  const bookmark = {
@@ -162,8 +191,8 @@ export class PanelAppDirectoryBookmarks {
162
191
  appId,
163
192
  projectPath,
164
193
  path,
165
- dev: info.dev,
166
- ino: info.ino,
194
+ dev: info.dev.toString(),
195
+ ino: info.ino.toString(),
167
196
  savedAt: Date.now(),
168
197
  };
169
198
  mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
@@ -173,18 +202,22 @@ export class PanelAppDirectoryBookmarks {
173
202
  const existing = previous.find((item) => item.appId === appId &&
174
203
  item.projectPath === projectPath &&
175
204
  item.path === path &&
176
- item.dev === info.dev &&
177
- item.ino === info.ino);
205
+ item.dev === bookmark.dev &&
206
+ item.ino === bookmark.ino);
178
207
  if (existing)
179
208
  bookmark.id = existing.id;
180
- write(this.file, [
181
- bookmark,
182
- ...previous.filter((item) => item.id !== bookmark.id &&
183
- !(item.appId === appId &&
184
- item.projectPath === projectPath &&
185
- item.path === path &&
186
- (item.dev !== info.dev || item.ino !== info.ino))),
187
- ].slice(0, MAX_RECORDS));
209
+ const retained = previous.filter((item) => item.id !== bookmark.id &&
210
+ !(item.dev !== null &&
211
+ item.appId === appId &&
212
+ item.projectPath === projectPath &&
213
+ item.path === path &&
214
+ (item.dev !== bookmark.dev || item.ino !== bookmark.ino)));
215
+ // Keep revocations even when the bounded store evicts older live grants:
216
+ // forgetting one could resurrect that UUID through a legacy-file fallback.
217
+ const revoked = retained.filter((item) => item.dev === null);
218
+ if (revoked.length >= MAX_RECORDS)
219
+ throw new Error("Panel directory bookmarks are full");
220
+ write(this.file, [bookmark, ...revoked, ...retained.filter((item) => item.dev !== null)].slice(0, MAX_RECORDS));
188
221
  }
189
222
  finally {
190
223
  release();
@@ -222,11 +255,11 @@ export class PanelAppDirectoryBookmarks {
222
255
  };
223
256
  assertProjectScope();
224
257
  try {
225
- const info = lstatSync(bookmark.path);
258
+ const info = lstatSync(bookmark.path, { bigint: true });
226
259
  if (!info.isDirectory() ||
227
260
  info.isSymbolicLink() ||
228
- info.dev !== bookmark.dev ||
229
- info.ino !== bookmark.ino ||
261
+ info.dev.toString() !== bookmark.dev ||
262
+ info.ino.toString() !== bookmark.ino ||
230
263
  realpathSync(bookmark.path) !== bookmark.path)
231
264
  throw new Error("Saved directory changed");
232
265
  }
@@ -1,13 +1,29 @@
1
+ /** What holds an execution, so a refused change can name it. Host-assigned only. */
2
+ export interface PanelExecutionHolder {
3
+ kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation";
4
+ /** Desktop WebContents id of the Panel instance that owns this execution. */
5
+ guestId?: number;
6
+ /** Trusted Host description, such as a native tool entry name. */
7
+ label?: string;
8
+ }
1
9
  export interface PanelExecutionScope {
2
10
  appId: string;
3
11
  /** Host-verified binding project, never a directory supplied by the guest. */
4
12
  projectPath: string;
13
+ holder?: PanelExecutionHolder;
14
+ }
15
+ export interface PanelExecutionBlocker extends PanelExecutionScope {
16
+ count: number;
5
17
  }
6
18
  /** Existing path aliases must not create separate admission domains. */
7
19
  export declare function panelExecutionProject(path: string): string;
8
20
  export declare class PanelExecutionBusyError extends Error {
21
+ /** Present when executions block a change; empty while another change runs. */
22
+ readonly blockers: PanelExecutionBlocker[];
9
23
  readonly status = 409;
10
- constructor(message: string);
24
+ constructor(message: string,
25
+ /** Present when executions block a change; empty while another change runs. */
26
+ blockers?: PanelExecutionBlocker[]);
11
27
  }
12
28
  /** One process owns admission for every Panel runtime sharing its installed catalog.
13
29
  * Acquire synchronously before authorization/IO; release after real execution cleanup,
@@ -21,6 +37,8 @@ export declare class PanelExecutionGate {
21
37
  run<T>(scope: PanelExecutionScope, work: () => Promise<T>): Promise<T>;
22
38
  /** Durable queues remain occupied even while paused and between process attempts. */
23
39
  register(source: () => Iterable<PanelExecutionScope>): () => void;
40
+ /** Executions that a change matching `matches` would wait for, grouped by holder. */
41
+ blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
24
42
  mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>): Promise<T>;
25
43
  }
26
44
  export declare const panelExecutionGate: PanelExecutionGate;