@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 +21 -2
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/skills/plashboard-admin/SKILL.md +6 -0
- package/src/config.ts +7 -2
- package/src/fill-runner.test.ts +120 -0
- package/src/fill-runner.ts +224 -33
- package/src/plugin.ts +528 -1
- package/src/runtime.ts +6 -4
- package/src/types.ts +2 -1
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": "
|
|
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
|
-
|
|
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.
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "plashboard",
|
|
3
3
|
"name": "Plashboard",
|
|
4
|
-
"version": "0.1.
|
|
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": "
|
|
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
|
@@ -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, '
|
|
54
|
-
const fillProvider = fillProviderRaw === 'command'
|
|
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
|
+
});
|
package/src/fill-runner.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
84
|
+
function defaultCommandRunner(argv: string[], optionsOrTimeout: number | CommandOptions): Promise<CommandRunResult> {
|
|
85
|
+
const options = normalizeCommandOptions(optionsOrTimeout);
|
|
54
86
|
return new Promise((resolve, reject) => {
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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;
|