pi-claude-supervisor 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -252,9 +252,10 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
252
252
  throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for concurrent sessions");
253
253
  }
254
254
  const taskId = randomUUID();
255
- const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport, tmuxSession
256
- ? { handoff: { sessionName: tmuxSession, tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET } }
257
- : {});
255
+ const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport, {
256
+ startup: taskAutomation,
257
+ ...(tmuxSession ? { handoff: { sessionName: tmuxSession, tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET } } : {}),
258
+ });
258
259
  cwdLeases.set(taskId, lease);
259
260
  if (shuttingDown) {
260
261
  await releaseLease(taskId);
@@ -286,6 +287,42 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
286
287
  env: selectedWorkerEnvironment(taskAutomation),
287
288
  approval,
288
289
  automation: taskAutomation,
290
+ retainCgroupUntilLeaseRelease: taskAutomation,
291
+ onWorkerStartup: taskAutomation
292
+ ? async (startupHandle) => {
293
+ await lease.updateWorker({
294
+ transport: adapter.capabilities().transport,
295
+ workerId: startupHandle.id,
296
+ cgroupPath: startupHandle.cgroupPath,
297
+ sessionName: startupHandle.sessionName,
298
+ tmuxSocket: startupHandle.tmuxSocket,
299
+ ownership: startupHandle.ownership,
300
+ retainCgroupUntilLeaseRelease: startupHandle.retainCgroupUntilLeaseRelease,
301
+ }, { preserveStartup: true });
302
+ }
303
+ : undefined,
304
+ onWorkerPrepared: taskAutomation
305
+ ? async (preparedHandle) => {
306
+ await lease.updateWorker({
307
+ transport: adapter.capabilities().transport,
308
+ ...(await workerIdentity(preparedHandle)),
309
+ sessionName: preparedHandle.sessionName,
310
+ tmuxSocket: preparedHandle.tmuxSocket,
311
+ ownership: preparedHandle.ownership,
312
+ }, { preserveStartup: true });
313
+ }
314
+ : undefined,
315
+ onWorkerPreSpawn: taskAutomation
316
+ ? async (preSpawnHandle) => {
317
+ await lease.updateWorker({
318
+ transport: adapter.capabilities().transport,
319
+ ...(await workerIdentity(preSpawnHandle)),
320
+ sessionName: preSpawnHandle.sessionName,
321
+ tmuxSocket: preSpawnHandle.tmuxSocket,
322
+ ownership: preSpawnHandle.ownership,
323
+ });
324
+ }
325
+ : undefined,
289
326
  tmuxSession,
290
327
  tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
291
328
  tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
@@ -453,14 +490,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
453
490
  const automaticRecovery = record.spec?.autonomy.unattended !== false;
454
491
  if (automaticRecovery && !record.resolvedExecutable) throw new Error("automatic recovery requires a persisted resolved Claude executable identity");
455
492
  const approval = record.approval;
456
- const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, takeover
457
- ? {
493
+ const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, {
494
+ startup: automaticRecovery,
495
+ ...(takeover ? {
458
496
  takeover: {
459
497
  taskId: record.taskId,
460
498
  ...(staleRecovery ? { beforeReplace: () => decisionStore.reconcileStaleRecovery(record.taskId) } : {}),
461
499
  },
462
- }
463
- : {});
500
+ } : {}),
501
+ });
464
502
  if (staleRecovery && lease.replacedTaskId !== record.taskId) {
465
503
  await lease.release();
466
504
  throw new Error(`Stale Decision Worker recovery has no verified old lease to take over: ${record.taskId}`);
@@ -507,6 +545,42 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
507
545
  env: selectedWorkerEnvironment(automaticRecovery),
508
546
  approval,
509
547
  automation: automaticRecovery,
548
+ retainCgroupUntilLeaseRelease: automaticRecovery,
549
+ onWorkerStartup: automaticRecovery
550
+ ? async (startupHandle) => {
551
+ await lease.updateWorker({
552
+ transport: adapter.capabilities().transport,
553
+ workerId: startupHandle.id,
554
+ cgroupPath: startupHandle.cgroupPath,
555
+ sessionName: startupHandle.sessionName,
556
+ tmuxSocket: startupHandle.tmuxSocket,
557
+ ownership: startupHandle.ownership,
558
+ retainCgroupUntilLeaseRelease: startupHandle.retainCgroupUntilLeaseRelease,
559
+ }, { preserveStartup: true });
560
+ }
561
+ : undefined,
562
+ onWorkerPrepared: automaticRecovery
563
+ ? async (preparedHandle) => {
564
+ await lease.updateWorker({
565
+ transport: adapter.capabilities().transport,
566
+ ...(await workerIdentity(preparedHandle)),
567
+ sessionName: preparedHandle.sessionName,
568
+ tmuxSocket: preparedHandle.tmuxSocket,
569
+ ownership: preparedHandle.ownership,
570
+ }, { preserveStartup: true });
571
+ }
572
+ : undefined,
573
+ onWorkerPreSpawn: automaticRecovery
574
+ ? async (preSpawnHandle) => {
575
+ await lease.updateWorker({
576
+ transport: adapter.capabilities().transport,
577
+ ...(await workerIdentity(preSpawnHandle)),
578
+ sessionName: preSpawnHandle.sessionName,
579
+ tmuxSocket: preSpawnHandle.tmuxSocket,
580
+ ownership: preSpawnHandle.ownership,
581
+ });
582
+ }
583
+ : undefined,
510
584
  ...(automaticRecovery && record.resolvedExecutable ? { expectedClaudeExecutable: record.resolvedExecutable } : {}),
511
585
  maxTurns: record.maxTurns,
512
586
  deadlineMs: record.deadlineMs,
@@ -768,6 +842,7 @@ function formatSessions(sessions: Map<string, Supervisor>, recoverable: Decision
768
842
  }
769
843
 
770
844
  function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
845
+ if (automatic) return automaticWorkerEnvironment(process.env);
771
846
  const result: NodeJS.ProcessEnv = {};
772
847
  const names = (process.env.PI_CLAUDE_SUPERVISOR_WORKER_ENV ?? "")
773
848
  .split(",")
@@ -776,7 +851,7 @@ function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
776
851
  for (const name of names) {
777
852
  if (process.env[name] !== undefined) result[name] = process.env[name];
778
853
  }
779
- return automatic ? automaticWorkerEnvironment(result) : result;
854
+ return result;
780
855
  }
781
856
 
782
857
  async function readTaskSpecFile(path: string, cwd: string): Promise<TaskSpec> {
package/src/policy.ts CHANGED
@@ -11,14 +11,12 @@ export interface PolicyResult {
11
11
  /**
12
12
  * Permission decisions are autonomous for local development. A denial is a
13
13
  * hard capability boundary, not a request for a human to approve the next
14
- * turn. AskUserQuestion is denied so the Worker can restate the question as
15
- * ordinary text and the Decision Worker can answer it from task evidence.
14
+ * turn. Claude's built-in tools, agents, background tasks and MCP tools are
15
+ * allowed; only the explicit unattended interaction and repository/remote
16
+ * authority boundaries below remain special-cased.
16
17
  */
17
- const allowedWorkerTools = new Set(["Bash", "Edit", "Glob", "Grep", "Read", "Write", "NotebookEdit"]);
18
-
19
18
  export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
20
19
  if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
21
- if (!allowedWorkerTools.has(toolName)) return { decision: "deny", reason: `Worker tool is outside the automatic allowlist: ${toolName}` };
22
20
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
23
21
  const paths = fileToolPaths(input);
24
22
  if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
@@ -30,9 +28,6 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
30
28
  ? (input as { command: string }).command
31
29
  : "";
32
30
  if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
33
- if (containsNestedClaude(command)) {
34
- return { decision: "deny", reason: "Worker cannot start a nested Claude or background Reviewer" };
35
- }
36
31
  return evaluateCommand("bash", ["-lc", command]);
37
32
  }
38
33
 
@@ -95,8 +90,7 @@ function isGitMetadataPath(value: string, cwd: string): boolean {
95
90
 
96
91
  const deniedPatterns = [
97
92
  /\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
98
- /\b(?:ssh|scp|sftp|rsync)\b/iu,
99
- /\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\.)/iu,
93
+ /\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|(?:^|\s)(?:-d|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+)))[\s\S]*https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu,
100
94
  /(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
101
95
  /--(?:allow-)?dangerously-skip-permissions\b/iu,
102
96
  /--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
@@ -117,6 +111,18 @@ const protectedBranches = new Set(["main", "master", "trunk", "integration", "de
117
111
  const protectedBranchOperations = new Set(["checkout", "switch", "branch", "reset", "restore", "worktree", "update-ref", "symbolic-ref"]);
118
112
  const remoteOperations = new Set(["push", "merge", "send-pack", "receive-pack", "update-ref"]);
119
113
 
114
+ function containsRemoteCliMutation(command: string): boolean {
115
+ return /\b(?:gh|glab|hub)\b[\s\S]*(?:\b(?:pr|mr|pull-request)\s+(?:merge|create|close|delete|edit|comment)\b|\b(?:release)\s+(?:create|delete|edit|upload)\b|\bapi\b[\s\S]*(?:-X|--request|--method(?:=|\s+))(?:\s*=?)\s*(?:POST|PUT|PATCH|DELETE)\b)/iu.test(command);
116
+ }
117
+
118
+ function containsRemoteHttpMutation(command: string): boolean {
119
+ const hasHttpClient = /\b(?:curl|wget)\b/iu.test(command);
120
+ const hasMutationFlag = /(?:-X\s*(?:POST|PUT|PATCH|DELETE)\b|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)\b)/iu.test(command)
121
+ || /(?:^|\s)(?:-d\b|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+))/iu.test(command);
122
+ const hasProtectedEndpoint = /https:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(command);
123
+ return hasHttpClient && hasMutationFlag && hasProtectedEndpoint;
124
+ }
125
+
120
126
  export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
121
127
  const normalized = command.trim();
122
128
  if (!normalized) return { decision: "deny", reason: "empty command" };
@@ -132,8 +138,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
132
138
  const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
133
139
  const hasGitTransport = lower.some((value) => /^(?:git-(?:send|receive|upload)-pack)$/u.test(value));
134
140
  const hasRemoteOperation = lower.some((value) => remoteOperations.has(value));
135
- const hasGhRemote = lower.some((value) => value === "gh" || value === "glab" || value === "hub")
136
- && lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
141
+ const hasGhRemote = containsRemoteCliMutation(canonical);
137
142
  const hasPackagePublication = lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish");
138
143
  const hasGitAliasConfiguration = hasGit && lower.some((value) => /^alias\.[^=]*(?:=|$)/u.test(value));
139
144
  if (depth < 4) {
@@ -152,7 +157,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
152
157
  }
153
158
  if (/\bgit\b[\s\S]*\b(?:push|merge|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
154
159
  || /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
155
- || /\b(?:gh|glab|hub)\b[\s\S]*\b(?:api|pr|mr|pull-request|release)\b/iu.test(canonical)) {
160
+ || containsRemoteCliMutation(canonical)) {
156
161
  return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
157
162
  }
158
163
  if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
@@ -181,27 +186,6 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
181
186
  return undefined;
182
187
  }
183
188
 
184
- function containsNestedClaude(command: string, depth = 0): boolean {
185
- // Bash can hand an opaque quoted program to Python, Node, eval, env,
186
- // command, or another interpreter. Once this permission boundary sees a
187
- // Claude executable reference, fail closed rather than trying to prove which
188
- // wrapper will eventually exec it. This intentionally rejects harmless prose
189
- // mentions too; false positives cannot grant a nested Worker capability.
190
- if (/\b(?:claude(?:\.exe)?|review(?:er)?|code[-_ ]?review|read[-_ ]?only[-_ ]?review|independent[-_ ]?review|codex|cursor(?:-agent)?|aider|opencode|gemini)\b/iu.test(command)) return true;
191
- if (depth > 4) return false;
192
- const lexical = lexShell(command);
193
- if (lexical.error) return true;
194
- for (const token of lexical.tokens) {
195
- if (token.operator) continue;
196
- const executable = token.value.split(/[\\/]/u).at(-1)?.toLowerCase();
197
- if (executable === "claude" || executable === "claude.exe") return true;
198
- // Quote concatenation such as c'l'a'u'd'e is normalized by the shell
199
- // lexer into one token. Recurse into quoted interpreter payloads too.
200
- if (/\s/u.test(token.value) && containsNestedClaude(token.value, depth + 1)) return true;
201
- }
202
- return false;
203
- }
204
-
205
189
  function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
206
190
  const nested: string[] = [];
207
191
  const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
@@ -237,6 +221,9 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
237
221
  if (!canonical) return { decision: "deny", reason: "empty command" };
238
222
  const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
239
223
  if (boundary) return boundary;
224
+ if (containsRemoteHttpMutation(canonical)) {
225
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
226
+ }
240
227
  if (deniedPatterns.some((pattern) => pattern.test(canonical))) {
241
228
  if (/\b(?:curl|wget)\b[\s\S]*(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(canonical)) {
242
229
  return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
package/src/supervisor.ts CHANGED
@@ -7,7 +7,7 @@ import { PiDecisionWorker, type DecisionAction, type DecisionWorkerFactory, type
7
7
  import { collectRepositoryEvidence, repositoryBranch, repositoryCommitExists, repositoryHead, repositoryWorkTree, verifyAll, type RepositoryEvidence, type VerificationCommand } from "./verifier.ts";
8
8
  import { normalizeTaskSpec } from "./acceptance.ts";
9
9
  import { redactSensitive } from "./redaction.ts";
10
- import { assertTrustedAutomaticClaudeExecutable, automaticClaudeArgs, automaticWorkerEnvironment } from "./worker/environment.ts";
10
+ import { assertAutomaticClaudePermissionConfiguration, assertTrustedAutomaticClaudeExecutable, automaticClaudeArgs, automaticWorkerEnvironment } from "./worker/environment.ts";
11
11
  import { normalizeReviewReport, type ReviewInput, type TaskReviewer } from "./reviewer.ts";
12
12
  import type {
13
13
  AcceptanceReport,
@@ -93,6 +93,14 @@ export interface SupervisorStartOptions {
93
93
  sendInitialInput?: boolean;
94
94
  /** Enable the event-driven Pi Decision Worker. Automatic mode requires claude-jsonl. */
95
95
  automation?: boolean;
96
+ /** Index-owned cwd leases retain the empty automatic cgroup until release. */
97
+ retainCgroupUntilLeaseRelease?: boolean;
98
+ /** Persist the planned adapter resource identity before resource creation. */
99
+ onWorkerStartup?: (handle: WorkerHandle) => Promise<void> | void;
100
+ /** Persist the cgroup identity before creating external Worker resources. */
101
+ onWorkerPrepared?: (handle: WorkerHandle) => Promise<void> | void;
102
+ /** Persist the adapter's final resource identity before Worker spawn. */
103
+ onWorkerPreSpawn?: (handle: WorkerHandle) => Promise<void> | void;
96
104
  /** Persistent Pi session location for the Decision Worker. */
97
105
  decisionSessionFile?: string;
98
106
  decisionSessionDir?: string;
@@ -267,10 +275,13 @@ export class Supervisor {
267
275
  if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
268
276
  throw new Error("automatic supervision requires the claude-jsonl or automated tmux transport");
269
277
  }
270
- const workerEnvironment = this.#automation ? automaticWorkerEnvironment(options.env) : options.env;
278
+ const workerEnvironment = this.#automation
279
+ ? automaticWorkerEnvironment(options.env)
280
+ : options.env;
271
281
  if (this.#automation) {
272
282
  trustedWorkerCommand = await assertTrustedAutomaticClaudeExecutable(options.command, options.expectedClaudeExecutable);
273
283
  workerArgs = automaticClaudeArgs(options.command, options.args);
284
+ await assertAutomaticClaudePermissionConfiguration(options.cwd, workerArgs, workerEnvironment);
274
285
  await this.#appendEvent({ type: "worker_executable_pinned", taskId, data: { command: options.command, resolvedExecutable: trustedWorkerCommand } });
275
286
  }
276
287
  await this.#adapter.preflight?.({
@@ -330,13 +341,18 @@ export class Supervisor {
330
341
  tmuxExpectedIdentity: options.tmuxExpectedIdentity,
331
342
  sendInitialInput: options.sendInitialInput,
332
343
  automatic: this.#automation,
344
+ retainCgroupUntilLeaseRelease: this.#automation && options.retainCgroupUntilLeaseRelease === true,
333
345
  eventListener: (event) => this.#receiveWorkerEvent(event),
334
346
  env: workerEnvironment,
335
347
  abortSignal: startAbortController.signal,
336
348
  startupToken: this.#startToken,
349
+ onWorkerStartup: this.#automation ? options.onWorkerStartup : undefined,
350
+ onWorkerPrepared: this.#automation ? options.onWorkerPrepared : undefined,
337
351
  preSpawnCheck: this.#automation
338
- ? async () => {
352
+ ? async (provisionalHandle) => {
353
+ if (provisionalHandle) await options.onWorkerPreSpawn?.(provisionalHandle);
339
354
  await automaticRepositoryBoundary(options.cwd, this.#task!.baseCommit, this.#task!.baseBranch, startAbortController.signal, startupHead);
355
+ await assertAutomaticClaudePermissionConfiguration(options.cwd, workerArgs ?? [], workerEnvironment);
340
356
  this.#assertStartNotAborted(startAbortController.signal);
341
357
  }
342
358
  : undefined,
package/src/types.ts CHANGED
@@ -54,15 +54,23 @@ export interface WorkerStartInput {
54
54
  sendInitialInput?: boolean;
55
55
  /** Automatic mode selects the structured tmux bridge instead of the manual TUI transport. */
56
56
  automatic?: boolean;
57
+ /** Keep an empty automatic cgroup until the owning cwd lease is finalized. */
58
+ retainCgroupUntilLeaseRelease?: boolean;
59
+ /** Persist the planned automatic resource identity before adapter setup. */
60
+ onWorkerStartup?: (provisionalHandle: WorkerHandle) => Promise<void> | void;
61
+ /** Persist the cgroup identity before creating any external Worker resource. */
62
+ onWorkerPrepared?: (provisionalHandle: WorkerHandle) => Promise<void> | void;
57
63
  /** Cancel startup before a worker is fully returned to the supervisor. */
58
64
  abortSignal?: AbortSignal;
59
65
  /** Internal token that scopes out-of-band startup cancellation. */
60
66
  startupToken?: string;
61
67
  /**
62
- * Automatic-start repository assertion. Built-in adapters invoke this after
63
- * all asynchronous setup and immediately before spawning the Worker.
68
+ * Automatic-start repository/settings assertion. Built-in adapters invoke
69
+ * this after asynchronous setup; the tmux bridge repeats its permission
70
+ * check immediately before spawning the Claude child. The provisional
71
+ * handle contains the cgroup identity before Worker spawn.
64
72
  */
65
- preSpawnCheck?: () => Promise<void>;
73
+ preSpawnCheck?: (provisionalHandle?: WorkerHandle) => Promise<void>;
66
74
  }
67
75
 
68
76
  export interface WorkerHandle {
@@ -76,6 +84,11 @@ export interface WorkerHandle {
76
84
  ownership?: "owned" | "adopted";
77
85
  /** Verified process-boundary metadata persisted in the cwd lease registry. */
78
86
  cgroupPath?: string;
87
+ cgroupIdentity?: { device: string; inode: string };
88
+ /** Automatic adapters retain the empty cgroup until the cwd lease is released. */
89
+ retainCgroupUntilLeaseRelease?: boolean;
90
+ tmuxServerPid?: number;
91
+ tmuxServerStartTime?: string;
79
92
  tmuxTarget?: string;
80
93
  tmuxPaneId?: string;
81
94
  paneStartTime?: string;