@jhytabest/plashboard 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -35,7 +35,8 @@ Add to `openclaw.json`:
35
35
  "default_retry_count": 1,
36
36
  "retry_backoff_seconds": 20,
37
37
  "session_timeout_seconds": 90,
38
- "fill_provider": "mock",
38
+ "fill_provider": "openclaw",
39
+ "openclaw_fill_agent_id": "main",
39
40
  "display_profile": {
40
41
  "width_px": 1920,
41
42
  "height_px": 1080,
@@ -51,11 +52,15 @@ Add to `openclaw.json`:
51
52
  }
52
53
  ```
53
54
 
54
- For real model runs, switch `fill_provider` to `command` and provide `fill_command`.
55
+ `fill_provider: "openclaw"` is the default real mode and calls `openclaw agent` directly.
56
+ Use `fill_provider: "command"` only if you need a custom external runner.
55
57
 
56
58
  ## Runtime Command
57
59
 
58
60
  ```text
61
+ /plashboard setup [openclaw [agent_id]|mock|command <fill_command>]
62
+ /plashboard expose-guide [local_url] [https_port]
63
+ /plashboard expose-check [local_url] [https_port]
59
64
  /plashboard init
60
65
  /plashboard status
61
66
  /plashboard list
@@ -66,6 +71,20 @@ For real model runs, switch `fill_provider` to `command` and provide `fill_comma
66
71
  /plashboard set-display <width> <height> <safe_top> <safe_bottom>
67
72
  ```
68
73
 
74
+ Recommended first run:
75
+
76
+ ```text
77
+ /plashboard setup openclaw
78
+ /plashboard init
79
+ ```
80
+
81
+ Tailscale helper flow:
82
+
83
+ ```text
84
+ /plashboard expose-guide
85
+ /plashboard expose-check
86
+ ```
87
+
69
88
  ## Notes
70
89
 
71
90
  - The plugin includes an admin skill (`plashboard-admin`) for tool-guided management.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "plashboard",
3
3
  "name": "Plashboard",
4
- "version": "0.1.0",
4
+ "version": "0.1.6",
5
5
  "description": "Template-driven dashboard runtime with scheduled OpenClaw fills and safe publish.",
6
6
  "entry": "./src/index.ts",
7
7
  "skills": ["./skills/plashboard-admin"],
@@ -15,8 +15,9 @@
15
15
  "default_retry_count": { "type": "integer", "minimum": 0, "maximum": 5, "default": 1 },
16
16
  "retry_backoff_seconds": { "type": "integer", "minimum": 1, "maximum": 300, "default": 20 },
17
17
  "session_timeout_seconds": { "type": "integer", "minimum": 10, "maximum": 600, "default": 90 },
18
- "fill_provider": { "type": "string", "enum": ["command", "mock"], "default": "mock" },
18
+ "fill_provider": { "type": "string", "enum": ["command", "mock", "openclaw"], "default": "openclaw" },
19
19
  "fill_command": { "type": "string" },
20
+ "openclaw_fill_agent_id": { "type": "string", "default": "main" },
20
21
  "python_bin": { "type": "string", "default": "python3" },
21
22
  "writer_script_path": { "type": "string" },
22
23
  "dashboard_output_path": { "type": "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhytabest/plashboard",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Plashboard OpenClaw plugin runtime",
6
6
  "license": "MIT",
@@ -17,6 +17,9 @@ Use this skill for plashboard runtime administration.
17
17
 
18
18
  ## Required Tooling
19
19
  Always use plugin tools:
20
+ - `plashboard_setup`
21
+ - `plashboard_exposure_guide`
22
+ - `plashboard_exposure_check`
20
23
  - `plashboard_init`
21
24
  - `plashboard_template_create`
22
25
  - `plashboard_template_update`
@@ -36,6 +39,9 @@ Always use plugin tools:
36
39
  - Never ask the model to generate full dashboard structure when filling values.
37
40
 
38
41
  ## Command Shortcuts
42
+ - `/plashboard setup [openclaw [agent_id]|mock|command <fill_command>]`
43
+ - `/plashboard expose-guide [local_url] [https_port]`
44
+ - `/plashboard expose-check [local_url] [https_port]`
39
45
  - `/plashboard init`
40
46
  - `/plashboard status`
41
47
  - `/plashboard list`
package/src/config.ts CHANGED
@@ -50,8 +50,12 @@ export function resolveConfig(api: unknown): PlashboardConfig {
50
50
 
51
51
  const dataDir = asString(raw.data_dir, '/var/lib/openclaw/plash-data');
52
52
  const outputPath = asString(raw.dashboard_output_path, join(dataDir, 'dashboard.json'));
53
- const fillProviderRaw = asString(raw.fill_provider, 'mock');
54
- const fillProvider = fillProviderRaw === 'command' ? 'command' : 'mock';
53
+ const fillProviderRaw = asString(raw.fill_provider, 'openclaw');
54
+ const fillProvider = fillProviderRaw === 'command'
55
+ ? 'command'
56
+ : fillProviderRaw === 'mock'
57
+ ? 'mock'
58
+ : 'openclaw';
55
59
 
56
60
  return {
57
61
  data_dir: dataDir,
@@ -63,6 +67,7 @@ export function resolveConfig(api: unknown): PlashboardConfig {
63
67
  session_timeout_seconds: Math.max(10, Math.floor(asNumber(raw.session_timeout_seconds, 90))),
64
68
  fill_provider: fillProvider,
65
69
  fill_command: typeof raw.fill_command === 'string' ? raw.fill_command : undefined,
70
+ openclaw_fill_agent_id: asString(raw.openclaw_fill_agent_id, 'main'),
66
71
  python_bin: asString(raw.python_bin, 'python3'),
67
72
  writer_script_path: asString(raw.writer_script_path, DEFAULT_WRITER_PATH),
68
73
  dashboard_output_path: outputPath,
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createFillRunner } from './fill-runner.js';
3
+ import type { DashboardTemplate, FillRunContext, PlashboardConfig } from './types.js';
4
+
5
+ function template(): DashboardTemplate {
6
+ return {
7
+ id: 'ops',
8
+ name: 'Ops',
9
+ enabled: true,
10
+ schedule: {
11
+ mode: 'interval',
12
+ every_minutes: 5,
13
+ timezone: 'UTC'
14
+ },
15
+ base_dashboard: {
16
+ title: 'Ops',
17
+ summary: 'old summary'
18
+ },
19
+ fields: [
20
+ {
21
+ id: 'summary',
22
+ pointer: '/summary',
23
+ type: 'string',
24
+ prompt: 'Summarize current status',
25
+ required: true
26
+ }
27
+ ]
28
+ };
29
+ }
30
+
31
+ function config(overrides: Partial<PlashboardConfig>): PlashboardConfig {
32
+ return {
33
+ data_dir: '/tmp/plash-test',
34
+ timezone: 'UTC',
35
+ scheduler_tick_seconds: 30,
36
+ max_parallel_runs: 1,
37
+ default_retry_count: 0,
38
+ retry_backoff_seconds: 1,
39
+ session_timeout_seconds: 30,
40
+ fill_provider: 'mock',
41
+ fill_command: undefined,
42
+ openclaw_fill_agent_id: 'main',
43
+ python_bin: 'python3',
44
+ writer_script_path: '/tmp/writer.py',
45
+ dashboard_output_path: '/tmp/dashboard.json',
46
+ layout_overflow_tolerance_px: 40,
47
+ display_profile: {
48
+ width_px: 1920,
49
+ height_px: 1080,
50
+ safe_top_px: 96,
51
+ safe_bottom_px: 106,
52
+ safe_side_px: 28,
53
+ layout_safety_margin_px: 24
54
+ },
55
+ model_defaults: {},
56
+ ...overrides
57
+ };
58
+ }
59
+
60
+ function context(): FillRunContext {
61
+ return {
62
+ template: template(),
63
+ currentValues: { summary: 'old summary' },
64
+ attempt: 1
65
+ };
66
+ }
67
+
68
+ describe('createFillRunner', () => {
69
+ it('parses openclaw json envelope output', async () => {
70
+ const commandRunner = vi.fn(async (_argv: string[], _options: unknown) => ({
71
+ stdout: JSON.stringify({
72
+ result: {
73
+ payloads: [
74
+ {
75
+ text: '{"values":{"summary":"new summary"}}'
76
+ }
77
+ ]
78
+ }
79
+ }),
80
+ stderr: '',
81
+ code: 0
82
+ }));
83
+
84
+ const runner = createFillRunner(
85
+ config({ fill_provider: 'openclaw', openclaw_fill_agent_id: 'ops' }),
86
+ { commandRunner }
87
+ );
88
+ const response = await runner.run(context());
89
+
90
+ expect(response.values.summary).toBe('new summary');
91
+ expect(commandRunner).toHaveBeenCalledTimes(1);
92
+ const firstCall = commandRunner.mock.calls[0];
93
+ const argv = firstCall[0];
94
+ expect(argv.slice(0, 2)).toEqual(['openclaw', 'agent']);
95
+ expect(argv).toContain('--agent');
96
+ expect(argv).toContain('ops');
97
+ });
98
+
99
+ it('parses command runner fenced json output', async () => {
100
+ const commandRunner = vi.fn(async (_argv: string[], _options: unknown) => ({
101
+ stdout: '```json\n{"values":{"summary":"from command"}}\n```',
102
+ stderr: '',
103
+ code: 0
104
+ }));
105
+
106
+ const runner = createFillRunner(
107
+ config({ fill_provider: 'command', fill_command: 'echo "$PLASHBOARD_PROMPT_JSON"' }),
108
+ { commandRunner }
109
+ );
110
+ const response = await runner.run(context());
111
+
112
+ expect(response.values.summary).toBe('from command');
113
+ expect(commandRunner).toHaveBeenCalledTimes(1);
114
+ const firstCall = commandRunner.mock.calls[0];
115
+ const argv = firstCall[0];
116
+ expect(argv.slice(0, 2)).toEqual(['sh', '-lc']);
117
+ const options = firstCall[1] as { env?: Record<string, string> };
118
+ expect(options.env?.PLASHBOARD_PROMPT_JSON).toContain('"template"');
119
+ });
120
+ });
@@ -1,6 +1,30 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import type { FillResponse, FillRunContext, FillRunner, PlashboardConfig } from './types.js';
3
3
 
4
+ type CommandOptions = {
5
+ timeoutMs: number;
6
+ env?: NodeJS.ProcessEnv;
7
+ input?: string;
8
+ };
9
+
10
+ export type CommandRunResult = {
11
+ stdout: string;
12
+ stderr: string;
13
+ code: number | null;
14
+ signal?: NodeJS.Signals | null;
15
+ killed?: boolean;
16
+ termination?: 'exit' | 'timeout' | 'signal' | string;
17
+ };
18
+
19
+ export type CommandRunner = (
20
+ argv: string[],
21
+ optionsOrTimeout: number | CommandOptions
22
+ ) => Promise<CommandRunResult>;
23
+
24
+ export interface FillRunnerDeps {
25
+ commandRunner?: CommandRunner;
26
+ }
27
+
4
28
  function buildPromptPayload(context: FillRunContext): Record<string, unknown> {
5
29
  return {
6
30
  instructions: {
@@ -32,6 +56,16 @@ function buildPromptPayload(context: FillRunContext): Record<string, unknown> {
32
56
  };
33
57
  }
34
58
 
59
+ function buildOpenClawMessage(context: FillRunContext): string {
60
+ const payload = buildPromptPayload(context);
61
+ return [
62
+ 'Fill dashboard fields from the provided template context.',
63
+ 'Return exactly one JSON object with this shape: {"values": {...}}.',
64
+ 'Do not include markdown, comments, explanations, or extra keys.',
65
+ JSON.stringify(payload)
66
+ ].join('\n\n');
67
+ }
68
+
35
69
  function mockValue(type: string, currentValue: unknown, fieldId: string): unknown {
36
70
  if (type === 'number') return typeof currentValue === 'number' ? currentValue : 0;
37
71
  if (type === 'boolean') return typeof currentValue === 'boolean' ? currentValue : false;
@@ -40,30 +74,35 @@ function mockValue(type: string, currentValue: unknown, fieldId: string): unknow
40
74
  return `updated ${fieldId} at ${now}`;
41
75
  }
42
76
 
43
- class MockFillRunner implements FillRunner {
44
- async run(context: FillRunContext): Promise<FillResponse> {
45
- const values: Record<string, unknown> = {};
46
- for (const field of context.template.fields) {
47
- values[field.id] = mockValue(field.type, context.currentValues[field.id], field.id);
48
- }
49
- return { values };
77
+ function normalizeCommandOptions(optionsOrTimeout: number | CommandOptions): CommandOptions {
78
+ if (typeof optionsOrTimeout === 'number') {
79
+ return { timeoutMs: optionsOrTimeout };
50
80
  }
81
+ return { timeoutMs: optionsOrTimeout.timeoutMs, env: optionsOrTimeout.env, input: optionsOrTimeout.input };
51
82
  }
52
83
 
53
- function runCommand(command: string, promptPayload: Record<string, unknown>, timeoutSeconds: number): Promise<string> {
84
+ function defaultCommandRunner(argv: string[], optionsOrTimeout: number | CommandOptions): Promise<CommandRunResult> {
85
+ const options = normalizeCommandOptions(optionsOrTimeout);
54
86
  return new Promise((resolve, reject) => {
55
- const child = spawn(command, {
56
- shell: true,
87
+ if (!Array.isArray(argv) || argv.length === 0 || !argv[0]) {
88
+ reject(new Error('command argv must include a binary name'));
89
+ return;
90
+ }
91
+
92
+ const child = spawn(argv[0], argv.slice(1), {
57
93
  env: {
58
94
  ...process.env,
59
- PLASHBOARD_PROMPT_JSON: JSON.stringify(promptPayload)
60
- }
95
+ ...(options.env || {})
96
+ },
97
+ stdio: 'pipe'
61
98
  });
62
99
 
100
+ const timeoutMs = Math.max(1000, Math.floor(options.timeoutMs));
101
+ let terminatedByTimeout = false;
63
102
  const timer = setTimeout(() => {
103
+ terminatedByTimeout = true;
64
104
  child.kill('SIGKILL');
65
- reject(new Error(`fill command timed out after ${timeoutSeconds}s`));
66
- }, timeoutSeconds * 1000);
105
+ }, timeoutMs);
67
106
 
68
107
  let stdout = '';
69
108
  let stderr = '';
@@ -71,7 +110,6 @@ function runCommand(command: string, promptPayload: Record<string, unknown>, tim
71
110
  child.stdout.on('data', (chunk) => {
72
111
  stdout += String(chunk);
73
112
  });
74
-
75
113
  child.stderr.on('data', (chunk) => {
76
114
  stderr += String(chunk);
77
115
  });
@@ -81,42 +119,195 @@ function runCommand(command: string, promptPayload: Record<string, unknown>, tim
81
119
  reject(error);
82
120
  });
83
121
 
84
- child.on('close', (code) => {
122
+ child.on('close', (code, signal) => {
85
123
  clearTimeout(timer);
86
- if (code !== 0) {
87
- reject(new Error(`fill command failed (code=${code}): ${stderr.trim() || 'no stderr'}`));
88
- return;
89
- }
90
- resolve(stdout.trim());
124
+ resolve({
125
+ stdout: stdout.trim(),
126
+ stderr: stderr.trim(),
127
+ code,
128
+ signal,
129
+ killed: terminatedByTimeout || code === null,
130
+ termination: terminatedByTimeout ? 'timeout' : signal ? 'signal' : 'exit'
131
+ });
91
132
  });
133
+
134
+ if (typeof options.input === 'string') {
135
+ child.stdin.write(options.input);
136
+ }
137
+ child.stdin.end();
92
138
  });
93
139
  }
94
140
 
141
+ function tryParseJson(input: string): unknown | undefined {
142
+ try {
143
+ return JSON.parse(input);
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ function stripCodeFence(input: string): string {
150
+ const trimmed = input.trim();
151
+ if (!trimmed.startsWith('```') || !trimmed.endsWith('```')) return trimmed;
152
+ const lines = trimmed.split('\n');
153
+ if (lines.length < 3) return trimmed;
154
+ return lines.slice(1, -1).join('\n').trim();
155
+ }
156
+
157
+ function parseJsonCandidate(input: string): unknown | undefined {
158
+ const trimmed = input.trim();
159
+ if (!trimmed) return undefined;
160
+
161
+ const direct = tryParseJson(trimmed);
162
+ if (direct !== undefined) return direct;
163
+
164
+ const unfenced = stripCodeFence(trimmed);
165
+ if (unfenced !== trimmed) {
166
+ const parsed = tryParseJson(unfenced);
167
+ if (parsed !== undefined) return parsed;
168
+ }
169
+
170
+ const start = trimmed.indexOf('{');
171
+ const end = trimmed.lastIndexOf('}');
172
+ if (start >= 0 && end > start) {
173
+ const maybeObject = trimmed.slice(start, end + 1);
174
+ const parsed = tryParseJson(maybeObject);
175
+ if (parsed !== undefined) return parsed;
176
+ }
177
+
178
+ return undefined;
179
+ }
180
+
181
+ function asObject(value: unknown): Record<string, unknown> | null {
182
+ return value && typeof value === 'object' && !Array.isArray(value)
183
+ ? (value as Record<string, unknown>)
184
+ : null;
185
+ }
186
+
187
+ function extractFillResponse(value: unknown, depth = 0): FillResponse | null {
188
+ if (depth > 10) return null;
189
+
190
+ if (typeof value === 'string') {
191
+ const parsed = parseJsonCandidate(value);
192
+ if (parsed !== undefined) {
193
+ return extractFillResponse(parsed, depth + 1);
194
+ }
195
+ return null;
196
+ }
197
+
198
+ if (Array.isArray(value)) {
199
+ for (const item of value) {
200
+ const found = extractFillResponse(item, depth + 1);
201
+ if (found) return found;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ const objectValue = asObject(value);
207
+ if (!objectValue) return null;
208
+
209
+ const valuesRecord = asObject(objectValue.values);
210
+ if (valuesRecord) {
211
+ return { values: valuesRecord };
212
+ }
213
+
214
+ for (const nested of Object.values(objectValue)) {
215
+ const found = extractFillResponse(nested, depth + 1);
216
+ if (found) return found;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function parseFillResponse(output: string, source: string): FillResponse {
222
+ const extracted = extractFillResponse(output);
223
+ if (!extracted) {
224
+ throw new Error(`${source} output did not include a valid {"values": ...} JSON object`);
225
+ }
226
+ return extracted;
227
+ }
228
+
229
+ async function runAndReadStdout(
230
+ commandRunner: CommandRunner,
231
+ argv: string[],
232
+ optionsOrTimeout: number | CommandOptions,
233
+ label: string
234
+ ): Promise<string> {
235
+ const result = await commandRunner(argv, optionsOrTimeout);
236
+ if (result.code !== 0) {
237
+ const reason = result.stderr || result.stdout || result.termination || `exit=${String(result.code)}`;
238
+ throw new Error(`${label} failed: ${reason}`);
239
+ }
240
+ return result.stdout.trim();
241
+ }
242
+
243
+ class MockFillRunner implements FillRunner {
244
+ async run(context: FillRunContext): Promise<FillResponse> {
245
+ const values: Record<string, unknown> = {};
246
+ for (const field of context.template.fields) {
247
+ values[field.id] = mockValue(field.type, context.currentValues[field.id], field.id);
248
+ }
249
+ return { values };
250
+ }
251
+ }
252
+
95
253
  class CommandFillRunner implements FillRunner {
96
- constructor(private readonly config: PlashboardConfig) {}
254
+ constructor(
255
+ private readonly config: PlashboardConfig,
256
+ private readonly commandRunner: CommandRunner
257
+ ) {}
97
258
 
98
259
  async run(context: FillRunContext): Promise<FillResponse> {
99
260
  if (!this.config.fill_command) {
100
261
  throw new Error('fill_provider=command but fill_command is not configured');
101
262
  }
102
263
 
103
- const promptPayload = buildPromptPayload(context);
104
- const output = await runCommand(this.config.fill_command, promptPayload, this.config.session_timeout_seconds);
264
+ const output = await runAndReadStdout(
265
+ this.commandRunner,
266
+ ['sh', '-lc', this.config.fill_command],
267
+ {
268
+ timeoutMs: this.config.session_timeout_seconds * 1000,
269
+ env: {
270
+ PLASHBOARD_PROMPT_JSON: JSON.stringify(buildPromptPayload(context))
271
+ }
272
+ },
273
+ 'fill command'
274
+ );
105
275
 
106
- let parsed: unknown;
107
- try {
108
- parsed = JSON.parse(output);
109
- } catch {
110
- throw new Error('fill command returned non-JSON output');
111
- }
276
+ return parseFillResponse(output, 'fill command');
277
+ }
278
+ }
279
+
280
+ class OpenClawFillRunner implements FillRunner {
281
+ constructor(
282
+ private readonly config: PlashboardConfig,
283
+ private readonly commandRunner: CommandRunner
284
+ ) {}
112
285
 
113
- return parsed as FillResponse;
286
+ async run(context: FillRunContext): Promise<FillResponse> {
287
+ const agentId = (this.config.openclaw_fill_agent_id || 'main').trim() || 'main';
288
+ const timeoutSeconds = Math.max(10, Math.floor(this.config.session_timeout_seconds));
289
+ const message = buildOpenClawMessage(context);
290
+
291
+ const output = await runAndReadStdout(
292
+ this.commandRunner,
293
+ ['openclaw', 'agent', '--agent', agentId, '--message', message, '--json', '--timeout', String(timeoutSeconds)],
294
+ {
295
+ timeoutMs: (timeoutSeconds + 30) * 1000
296
+ },
297
+ 'openclaw fill'
298
+ );
299
+
300
+ return parseFillResponse(output, 'openclaw fill');
114
301
  }
115
302
  }
116
303
 
117
- export function createFillRunner(config: PlashboardConfig): FillRunner {
304
+ export function createFillRunner(config: PlashboardConfig, deps: FillRunnerDeps = {}): FillRunner {
305
+ const commandRunner = deps.commandRunner || defaultCommandRunner;
118
306
  if (config.fill_provider === 'command') {
119
- return new CommandFillRunner(config);
307
+ return new CommandFillRunner(config, commandRunner);
308
+ }
309
+ if (config.fill_provider === 'openclaw') {
310
+ return new OpenClawFillRunner(config, commandRunner);
120
311
  }
121
312
  return new MockFillRunner();
122
313
  }
package/src/plugin.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { access, stat } from 'node:fs/promises';
1
4
  import type { DisplayProfile, ToolResponse } from './types.js';
2
5
  import { resolveConfig } from './config.js';
3
6
  import { PlashboardRuntime } from './runtime.js';
@@ -11,6 +14,32 @@ type UnknownApi = {
11
14
  warn?: (...args: unknown[]) => void;
12
15
  error?: (...args: unknown[]) => void;
13
16
  };
17
+ runtime?: {
18
+ config?: {
19
+ loadConfig?: () => unknown;
20
+ writeConfigFile?: (nextConfig: unknown) => Promise<void>;
21
+ };
22
+ system?: {
23
+ runCommandWithTimeout?: (
24
+ argv: string[],
25
+ optionsOrTimeout: number | {
26
+ timeoutMs: number;
27
+ cwd?: string;
28
+ input?: string;
29
+ env?: NodeJS.ProcessEnv;
30
+ windowsVerbatimArguments?: boolean;
31
+ noOutputTimeoutMs?: number;
32
+ }
33
+ ) => Promise<{
34
+ stdout: string;
35
+ stderr: string;
36
+ code: number | null;
37
+ signal?: NodeJS.Signals | null;
38
+ killed?: boolean;
39
+ termination?: string;
40
+ }>;
41
+ };
42
+ };
14
43
  config?: unknown;
15
44
  pluginConfig?: unknown;
16
45
  };
@@ -42,12 +71,417 @@ function asString(value: unknown): string {
42
71
  return typeof value === 'string' ? value : '';
43
72
  }
44
73
 
74
+ function asNumber(value: unknown): number | undefined {
75
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
76
+ if (typeof value === 'string' && value.trim()) {
77
+ const parsed = Number(value);
78
+ if (Number.isFinite(parsed)) return parsed;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ function asErrorMessage(error: unknown): string {
84
+ if (error instanceof Error && error.message) return error.message;
85
+ if (typeof error === 'string' && error.trim()) return error;
86
+ return 'unknown error';
87
+ }
88
+
89
+ type SetupParams = {
90
+ fill_provider?: 'mock' | 'command' | 'openclaw';
91
+ fill_command?: string;
92
+ openclaw_fill_agent_id?: string;
93
+ data_dir?: string;
94
+ scheduler_tick_seconds?: number;
95
+ session_timeout_seconds?: number;
96
+ width_px?: number;
97
+ height_px?: number;
98
+ safe_top_px?: number;
99
+ safe_bottom_px?: number;
100
+ safe_side_px?: number;
101
+ layout_safety_margin_px?: number;
102
+ };
103
+
104
+ type ExposureParams = {
105
+ local_url?: string;
106
+ tailscale_https_port?: number;
107
+ dashboard_output_path?: string;
108
+ };
109
+
110
+ type CommandExecResult = {
111
+ ok: boolean;
112
+ stdout: string;
113
+ stderr: string;
114
+ code: number | null;
115
+ error?: string;
116
+ };
117
+
118
+ function normalizeLocalUrl(raw: string | undefined): string {
119
+ const fallback = 'http://127.0.0.1:18888';
120
+ if (!raw || !raw.trim()) return fallback;
121
+ try {
122
+ const parsed = new URL(raw.trim());
123
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return fallback;
124
+ return parsed.toString();
125
+ } catch {
126
+ return fallback;
127
+ }
128
+ }
129
+
130
+ function normalizePort(raw: number | undefined, fallback: number): number {
131
+ const value = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : fallback;
132
+ return Math.max(1, Math.min(65535, value));
133
+ }
134
+
135
+ function runCommand(binary: string, args: string[], timeoutMs: number): Promise<CommandExecResult> {
136
+ return new Promise((resolve) => {
137
+ const child = spawn(binary, args, {
138
+ env: process.env
139
+ });
140
+
141
+ let stdout = '';
142
+ let stderr = '';
143
+ let settled = false;
144
+
145
+ const finish = (result: CommandExecResult) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ resolve(result);
149
+ };
150
+
151
+ const timer = setTimeout(() => {
152
+ child.kill('SIGKILL');
153
+ finish({
154
+ ok: false,
155
+ stdout,
156
+ stderr,
157
+ code: null,
158
+ error: `timed out after ${Math.floor(timeoutMs / 1000)}s`
159
+ });
160
+ }, timeoutMs);
161
+
162
+ child.stdout.on('data', (chunk) => {
163
+ stdout += String(chunk);
164
+ });
165
+ child.stderr.on('data', (chunk) => {
166
+ stderr += String(chunk);
167
+ });
168
+
169
+ child.on('error', (error) => {
170
+ clearTimeout(timer);
171
+ finish({
172
+ ok: false,
173
+ stdout,
174
+ stderr,
175
+ code: null,
176
+ error: asString((error as { message?: unknown }).message) || 'spawn failed'
177
+ });
178
+ });
179
+
180
+ child.on('close', (code) => {
181
+ clearTimeout(timer);
182
+ finish({
183
+ ok: code === 0,
184
+ stdout: stdout.trim(),
185
+ stderr: stderr.trim(),
186
+ code
187
+ });
188
+ });
189
+ });
190
+ }
191
+
192
+ async function buildExposureGuide(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
193
+ const localUrl = normalizeLocalUrl(params.local_url);
194
+ const httpsPort = normalizePort(asNumber(params.tailscale_https_port), 8444);
195
+ const dashboardPath = (params.dashboard_output_path || resolvedConfig.dashboard_output_path).trim();
196
+
197
+ return {
198
+ ok: true,
199
+ errors: [],
200
+ data: {
201
+ local_url: localUrl,
202
+ tailscale_https_port: httpsPort,
203
+ dashboard_output_path: dashboardPath,
204
+ commands: [
205
+ `tailscale serve status`,
206
+ `tailscale serve --https=${httpsPort} ${localUrl}`,
207
+ `tailscale serve status`,
208
+ `tailscale serve --https=${httpsPort} off`
209
+ ],
210
+ checks: [
211
+ `test -f ${dashboardPath}`,
212
+ `curl -I ${localUrl}`
213
+ ],
214
+ notes: [
215
+ 'plashboard only writes dashboard JSON; your local UI/server must serve it.',
216
+ 'the tailscale mapping reuses your existing tailnet identity.',
217
+ 'choose a port not already used by another tailscale serve mapping.'
218
+ ]
219
+ }
220
+ } satisfies ToolResponse<Record<string, unknown>>;
221
+ }
222
+
223
+ async function runExposureCheck(resolvedConfig: ReturnType<typeof resolveConfig>, params: ExposureParams = {}) {
224
+ const localUrl = normalizeLocalUrl(params.local_url);
225
+ const httpsPort = normalizePort(asNumber(params.tailscale_https_port), 8444);
226
+ const dashboardPath = (params.dashboard_output_path || resolvedConfig.dashboard_output_path).trim();
227
+ const errors: string[] = [];
228
+
229
+ let dashboardExists = false;
230
+ let dashboardSizeBytes: number | undefined;
231
+ let dashboardMtimeIso: string | undefined;
232
+
233
+ try {
234
+ await access(dashboardPath, fsConstants.R_OK);
235
+ const info = await stat(dashboardPath);
236
+ dashboardExists = true;
237
+ dashboardSizeBytes = info.size;
238
+ dashboardMtimeIso = info.mtime.toISOString();
239
+ } catch {
240
+ errors.push(`dashboard file is not readable: ${dashboardPath}`);
241
+ }
242
+
243
+ let localUrlOk = false;
244
+ let localStatusCode: number | undefined;
245
+ let localError: string | undefined;
246
+
247
+ try {
248
+ const controller = new AbortController();
249
+ const timer = setTimeout(() => controller.abort(), 5000);
250
+ const response = await fetch(localUrl, {
251
+ method: 'GET',
252
+ signal: controller.signal
253
+ });
254
+ clearTimeout(timer);
255
+ localStatusCode = response.status;
256
+ localUrlOk = response.status >= 200 && response.status < 500;
257
+ if (!localUrlOk) {
258
+ errors.push(`local dashboard URL returned status ${response.status}: ${localUrl}`);
259
+ }
260
+ } catch (error) {
261
+ localError = asErrorMessage(error);
262
+ errors.push(`local dashboard URL is not reachable: ${localUrl} (${localError})`);
263
+ }
264
+
265
+ const tailscale = await runCommand('tailscale', ['serve', 'status'], 8000);
266
+ const tailscaleOutput = `${tailscale.stdout}\n${tailscale.stderr}`.trim();
267
+ let tailscalePortConfigured = false;
268
+
269
+ if (!tailscale.ok) {
270
+ errors.push(`tailscale serve status failed: ${tailscale.error || tailscale.stderr || `exit ${tailscale.code}`}`);
271
+ } else {
272
+ tailscalePortConfigured = tailscaleOutput.includes(`:${httpsPort}`);
273
+ if (!tailscalePortConfigured) {
274
+ errors.push(`tailscale serve has no mapping for https port ${httpsPort}`);
275
+ }
276
+ }
277
+
278
+ return {
279
+ ok: errors.length === 0,
280
+ errors,
281
+ data: {
282
+ dashboard_output_path: dashboardPath,
283
+ dashboard_exists: dashboardExists,
284
+ dashboard_size_bytes: dashboardSizeBytes,
285
+ dashboard_mtime_utc: dashboardMtimeIso,
286
+ local_url: localUrl,
287
+ local_url_ok: localUrlOk,
288
+ local_status_code: localStatusCode,
289
+ local_error: localError,
290
+ tailscale_https_port: httpsPort,
291
+ tailscale_status_ok: tailscale.ok,
292
+ tailscale_port_configured: tailscalePortConfigured,
293
+ tailscale_status_excerpt: tailscaleOutput.slice(0, 1200)
294
+ }
295
+ } satisfies ToolResponse<Record<string, unknown>>;
296
+ }
297
+
298
+ async function runSetup(api: UnknownApi, resolvedConfig: ReturnType<typeof resolveConfig>, params: SetupParams = {}) {
299
+ const loadConfig = api.runtime?.config?.loadConfig;
300
+ const writeConfigFile = api.runtime?.config?.writeConfigFile;
301
+
302
+ if (!loadConfig || !writeConfigFile) {
303
+ return {
304
+ ok: false,
305
+ errors: ['setup is unavailable: runtime config API is not exposed by this OpenClaw build']
306
+ } satisfies ToolResponse<Record<string, unknown>>;
307
+ }
308
+
309
+ const rootConfig = asObject(loadConfig());
310
+ const plugins = asObject(rootConfig.plugins);
311
+ const entries = asObject(plugins.entries);
312
+ const currentEntry = asObject(entries.plashboard);
313
+ const currentPluginConfig = asObject(currentEntry.config);
314
+
315
+ const existingDisplay = asObject(currentPluginConfig.display_profile);
316
+ const displayProfile = {
317
+ width_px: Math.max(
318
+ 320,
319
+ Math.floor(asNumber(params.width_px) ?? asNumber(existingDisplay.width_px) ?? resolvedConfig.display_profile.width_px)
320
+ ),
321
+ height_px: Math.max(
322
+ 240,
323
+ Math.floor(asNumber(params.height_px) ?? asNumber(existingDisplay.height_px) ?? resolvedConfig.display_profile.height_px)
324
+ ),
325
+ safe_top_px: Math.max(
326
+ 0,
327
+ Math.floor(asNumber(params.safe_top_px) ?? asNumber(existingDisplay.safe_top_px) ?? resolvedConfig.display_profile.safe_top_px)
328
+ ),
329
+ safe_bottom_px: Math.max(
330
+ 0,
331
+ Math.floor(
332
+ asNumber(params.safe_bottom_px) ?? asNumber(existingDisplay.safe_bottom_px) ?? resolvedConfig.display_profile.safe_bottom_px
333
+ )
334
+ ),
335
+ safe_side_px: Math.max(
336
+ 0,
337
+ Math.floor(asNumber(params.safe_side_px) ?? asNumber(existingDisplay.safe_side_px) ?? resolvedConfig.display_profile.safe_side_px)
338
+ ),
339
+ layout_safety_margin_px: Math.max(
340
+ 0,
341
+ Math.floor(
342
+ asNumber(params.layout_safety_margin_px)
343
+ ?? asNumber(existingDisplay.layout_safety_margin_px)
344
+ ?? resolvedConfig.display_profile.layout_safety_margin_px
345
+ )
346
+ )
347
+ };
348
+
349
+ const currentProvider = asString(currentPluginConfig.fill_provider);
350
+ const selectedProvider =
351
+ params.fill_provider
352
+ || (currentProvider === 'command' || currentProvider === 'mock' || currentProvider === 'openclaw'
353
+ ? currentProvider
354
+ : resolvedConfig.fill_provider);
355
+ const selectedCommand = (
356
+ params.fill_command
357
+ || asString(currentPluginConfig.fill_command)
358
+ || asString(resolvedConfig.fill_command)
359
+ ).trim();
360
+ const selectedAgentId = (
361
+ params.openclaw_fill_agent_id
362
+ || asString(currentPluginConfig.openclaw_fill_agent_id)
363
+ || asString(resolvedConfig.openclaw_fill_agent_id)
364
+ || 'main'
365
+ ).trim();
366
+
367
+ if (selectedProvider === 'command' && !selectedCommand) {
368
+ return {
369
+ ok: false,
370
+ errors: ['fill_provider=command requires fill_command']
371
+ } satisfies ToolResponse<Record<string, unknown>>;
372
+ }
373
+ if (selectedProvider === 'openclaw' && !selectedAgentId) {
374
+ return {
375
+ ok: false,
376
+ errors: ['fill_provider=openclaw requires openclaw_fill_agent_id']
377
+ } satisfies ToolResponse<Record<string, unknown>>;
378
+ }
379
+
380
+ const nextPluginConfig: Record<string, unknown> = {
381
+ ...currentPluginConfig,
382
+ data_dir: params.data_dir || asString(currentPluginConfig.data_dir) || resolvedConfig.data_dir,
383
+ scheduler_tick_seconds: Math.max(
384
+ 5,
385
+ Math.floor(
386
+ asNumber(params.scheduler_tick_seconds)
387
+ ?? asNumber(currentPluginConfig.scheduler_tick_seconds)
388
+ ?? resolvedConfig.scheduler_tick_seconds
389
+ )
390
+ ),
391
+ session_timeout_seconds: Math.max(
392
+ 10,
393
+ Math.floor(
394
+ asNumber(params.session_timeout_seconds)
395
+ ?? asNumber(currentPluginConfig.session_timeout_seconds)
396
+ ?? resolvedConfig.session_timeout_seconds
397
+ )
398
+ ),
399
+ fill_provider: selectedProvider,
400
+ display_profile: displayProfile
401
+ };
402
+
403
+ if (selectedCommand) {
404
+ nextPluginConfig.fill_command = selectedCommand;
405
+ } else {
406
+ delete nextPluginConfig.fill_command;
407
+ }
408
+ if (selectedProvider === 'openclaw') {
409
+ nextPluginConfig.openclaw_fill_agent_id = selectedAgentId;
410
+ } else {
411
+ delete nextPluginConfig.openclaw_fill_agent_id;
412
+ }
413
+
414
+ const nextRootConfig = {
415
+ ...rootConfig,
416
+ plugins: {
417
+ ...plugins,
418
+ entries: {
419
+ ...entries,
420
+ plashboard: {
421
+ ...currentEntry,
422
+ enabled: true,
423
+ config: nextPluginConfig
424
+ }
425
+ }
426
+ }
427
+ };
428
+
429
+ await writeConfigFile(nextRootConfig);
430
+
431
+ return {
432
+ ok: true,
433
+ errors: [],
434
+ data: {
435
+ configured: true,
436
+ restart_required: true,
437
+ plugin_id: 'plashboard',
438
+ fill_provider: selectedProvider,
439
+ fill_command: selectedProvider === 'command' ? selectedCommand : undefined,
440
+ openclaw_fill_agent_id: selectedProvider === 'openclaw' ? selectedAgentId : undefined,
441
+ data_dir: nextPluginConfig.data_dir,
442
+ scheduler_tick_seconds: nextPluginConfig.scheduler_tick_seconds,
443
+ session_timeout_seconds: nextPluginConfig.session_timeout_seconds,
444
+ display_profile: displayProfile,
445
+ next_steps: [
446
+ 'restart OpenClaw gateway',
447
+ 'run /plashboard init'
448
+ ]
449
+ }
450
+ } satisfies ToolResponse<Record<string, unknown>>;
451
+ }
452
+
45
453
  export function registerPlashboardPlugin(api: UnknownApi): void {
46
454
  const config = resolveConfig(api);
455
+ const runtimeCommand = api.runtime?.system?.runCommandWithTimeout;
456
+ const fillCommandRunner = runtimeCommand
457
+ ? async (
458
+ argv: string[],
459
+ optionsOrTimeout: number | {
460
+ timeoutMs: number;
461
+ cwd?: string;
462
+ input?: string;
463
+ env?: NodeJS.ProcessEnv;
464
+ windowsVerbatimArguments?: boolean;
465
+ noOutputTimeoutMs?: number;
466
+ }
467
+ ) => {
468
+ const result = await runtimeCommand(argv, optionsOrTimeout);
469
+ return {
470
+ stdout: result.stdout,
471
+ stderr: result.stderr,
472
+ code: result.code,
473
+ signal: result.signal,
474
+ killed: result.killed,
475
+ termination: result.termination
476
+ };
477
+ }
478
+ : undefined;
47
479
  const runtime = new PlashboardRuntime(config, {
48
480
  info: (...args) => api.logger?.info?.(...args),
49
481
  warn: (...args) => api.logger?.warn?.(...args),
50
482
  error: (...args) => api.logger?.error?.(...args)
483
+ }, {
484
+ commandRunner: fillCommandRunner
51
485
  });
52
486
 
53
487
  api.registerService?.({
@@ -60,6 +494,66 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
60
494
  }
61
495
  });
62
496
 
497
+ api.registerTool?.({
498
+ name: 'plashboard_exposure_guide',
499
+ description: 'Return copy-paste commands to expose dashboard UI over existing Tailscale.',
500
+ optional: true,
501
+ parameters: {
502
+ type: 'object',
503
+ properties: {
504
+ local_url: { type: 'string' },
505
+ tailscale_https_port: { type: 'number' },
506
+ dashboard_output_path: { type: 'string' }
507
+ },
508
+ additionalProperties: false
509
+ },
510
+ execute: async (_toolCallId: unknown, params: ExposureParams = {}) =>
511
+ toToolResult(await buildExposureGuide(config, params))
512
+ });
513
+
514
+ api.registerTool?.({
515
+ name: 'plashboard_exposure_check',
516
+ description: 'Check dashboard file, local URL, and tailscale serve mapping health.',
517
+ optional: true,
518
+ parameters: {
519
+ type: 'object',
520
+ properties: {
521
+ local_url: { type: 'string' },
522
+ tailscale_https_port: { type: 'number' },
523
+ dashboard_output_path: { type: 'string' }
524
+ },
525
+ additionalProperties: false
526
+ },
527
+ execute: async (_toolCallId: unknown, params: ExposureParams = {}) =>
528
+ toToolResult(await runExposureCheck(config, params))
529
+ });
530
+
531
+ api.registerTool?.({
532
+ name: 'plashboard_setup',
533
+ description: 'Bootstrap or update plashboard plugin configuration in openclaw.json.',
534
+ optional: true,
535
+ parameters: {
536
+ type: 'object',
537
+ properties: {
538
+ fill_provider: { type: 'string', enum: ['mock', 'command', 'openclaw'] },
539
+ fill_command: { type: 'string' },
540
+ openclaw_fill_agent_id: { type: 'string' },
541
+ data_dir: { type: 'string' },
542
+ scheduler_tick_seconds: { type: 'number' },
543
+ session_timeout_seconds: { type: 'number' },
544
+ width_px: { type: 'number' },
545
+ height_px: { type: 'number' },
546
+ safe_top_px: { type: 'number' },
547
+ safe_bottom_px: { type: 'number' },
548
+ safe_side_px: { type: 'number' },
549
+ layout_safety_margin_px: { type: 'number' }
550
+ },
551
+ additionalProperties: false
552
+ },
553
+ execute: async (_toolCallId: unknown, params: SetupParams = {}) =>
554
+ toToolResult(await runSetup(api, config, params))
555
+ });
556
+
63
557
  api.registerTool?.({
64
558
  name: 'plashboard_init',
65
559
  description: 'Initialize plashboard state directories and optional default template.',
@@ -240,6 +734,39 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
240
734
  const args = asString(ctx.args).split(/\s+/).filter(Boolean);
241
735
  const [cmd, ...rest] = args;
242
736
 
737
+ if (cmd === 'expose-guide') {
738
+ const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
739
+ const portToken = rest.find((token) => /^[0-9]+$/.test(token));
740
+ return toCommandResult(
741
+ await buildExposureGuide(config, {
742
+ local_url: localUrl,
743
+ tailscale_https_port: portToken ? Number(portToken) : undefined
744
+ })
745
+ );
746
+ }
747
+ if (cmd === 'expose-check') {
748
+ const localUrl = rest.find((token) => token.startsWith('http://') || token.startsWith('https://'));
749
+ const portToken = rest.find((token) => /^[0-9]+$/.test(token));
750
+ return toCommandResult(
751
+ await runExposureCheck(config, {
752
+ local_url: localUrl,
753
+ tailscale_https_port: portToken ? Number(portToken) : undefined
754
+ })
755
+ );
756
+ }
757
+ if (cmd === 'setup') {
758
+ const mode = asString(rest[0]).toLowerCase();
759
+ const fillProvider = mode === 'command' || mode === 'mock' || mode === 'openclaw' ? mode : undefined;
760
+ const fillCommand = fillProvider === 'command' ? rest.slice(1).join(' ').trim() || undefined : undefined;
761
+ const fillAgentId = fillProvider === 'openclaw' ? (rest[1] || '').trim() || undefined : undefined;
762
+ return toCommandResult(
763
+ await runSetup(api, config, {
764
+ fill_provider: fillProvider,
765
+ fill_command: fillCommand,
766
+ openclaw_fill_agent_id: fillAgentId
767
+ })
768
+ );
769
+ }
243
770
  if (cmd === 'init') return toCommandResult(await runtime.init());
244
771
  if (cmd === 'status') return toCommandResult(await runtime.status());
245
772
  if (cmd === 'list') return toCommandResult(await runtime.templateList());
@@ -264,7 +791,7 @@ export function registerPlashboardPlugin(api: UnknownApi): void {
264
791
  return toCommandResult({
265
792
  ok: false,
266
793
  errors: [
267
- 'unknown command. supported: init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
794
+ 'unknown command. supported: setup [openclaw [agent_id]|mock|command <fill_command>], expose-guide [local_url] [https_port], expose-check [local_url] [https_port], init, status, list, activate <id>, delete <id>, copy <src> <new-id> [new-name] [activate], run <id>, set-display <width> <height> <top> <bottom>'
268
795
  ]
269
796
  });
270
797
  }
package/src/runtime.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  DashboardTemplate,
6
6
  DisplayProfile,
7
7
  FillResponse,
8
+ FillRunner,
8
9
  PlashboardConfig,
9
10
  PlashboardState,
10
11
  RunArtifact,
@@ -16,7 +17,7 @@ import { asErrorMessage, atomicWriteJson, deepClone, ensureDir, nowIso, sleep }
16
17
  import { collectCurrentValues, mergeTemplateValues, validateFieldPointers } from './merge.js';
17
18
  import { validateFillShape, validateTemplateShape } from './schema-validation.js';
18
19
  import { DashboardValidatorPublisher } from './publisher.js';
19
- import { createFillRunner } from './fill-runner.js';
20
+ import { createFillRunner, type FillRunnerDeps } from './fill-runner.js';
20
21
 
21
22
  interface Logger {
22
23
  info(message: string, ...args: unknown[]): void;
@@ -55,7 +56,7 @@ export class PlashboardRuntime {
55
56
  private readonly templateStore: TemplateStore;
56
57
  private readonly runStore: RunStore;
57
58
  private readonly publisher: DashboardValidatorPublisher;
58
- private readonly fillRunner;
59
+ private readonly fillRunner: FillRunner;
59
60
 
60
61
  private schedulerTimer: NodeJS.Timeout | null = null;
61
62
  private tickInProgress = false;
@@ -64,14 +65,15 @@ export class PlashboardRuntime {
64
65
 
65
66
  constructor(
66
67
  private readonly config: PlashboardConfig,
67
- private readonly logger: Logger = NOOP_LOGGER
68
+ private readonly logger: Logger = NOOP_LOGGER,
69
+ fillRunnerDeps: FillRunnerDeps = {}
68
70
  ) {
69
71
  this.paths = new Paths(config);
70
72
  this.stateStore = new StateStore(this.paths);
71
73
  this.templateStore = new TemplateStore(this.paths);
72
74
  this.runStore = new RunStore(this.paths);
73
75
  this.publisher = new DashboardValidatorPublisher(config);
74
- this.fillRunner = createFillRunner(config);
76
+ this.fillRunner = createFillRunner(config, fillRunnerDeps);
75
77
  }
76
78
 
77
79
  async start(): Promise<void> {
package/src/types.ts CHANGED
@@ -23,8 +23,9 @@ export interface PlashboardConfig {
23
23
  default_retry_count: number;
24
24
  retry_backoff_seconds: number;
25
25
  session_timeout_seconds: number;
26
- fill_provider: 'command' | 'mock';
26
+ fill_provider: 'command' | 'mock' | 'openclaw';
27
27
  fill_command?: string;
28
+ openclaw_fill_agent_id?: string;
28
29
  python_bin: string;
29
30
  writer_script_path: string;
30
31
  dashboard_output_path: string;