@hyperdreamer/pi-webui 1.14.1 → 1.15.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.
@@ -1,17 +1,103 @@
1
1
  // Generated from pi-webui-plugins/workspace-tasks/workspaceTasksClient.ts. Do not edit directly.
2
- import { TASKS_CONFIG_PATH, parseTasksConfigText } from "./config.js";
2
+ import { TASKS_CONFIG_PATH, parseTasksConfigText, serializeWorkspaceTasksConfig, } from "./config.js";
3
3
  export const tasksConfigMissingMessage = "No workspace tasks configured here.";
4
4
  export const tasksConfigMissingHint = `${TASKS_CONFIG_PATH} is optional. Create it in this workspace if you want custom tasks.`;
5
+ export const tasksConfigInvalidMessage = "Workspace tasks configuration is invalid.";
6
+ export const tasksConfigInvalidHint = `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`;
5
7
  export const tasksConfigUnavailableMessage = "Could not load workspace tasks.";
6
8
  export const tasksConfigRefreshHint = `Fix ${TASKS_CONFIG_PATH}, then click Refresh.`;
7
- const missingWorkspaceFileError = "Path does not exist";
8
- export async function loadWorkspaceTasksConfig(files) {
9
+ const configCache = new Map();
10
+ const workspaceRuntimes = new Map();
11
+ const subscribers = new Set();
12
+ let stateEpoch = 0;
13
+ export function loadWorkspaceTasksConfig(files) {
14
+ return readCurrentWorkspaceTasksConfig(files);
15
+ }
16
+ export function getWorkspaceTasksCacheEntry(workspaceKey) {
17
+ return configCache.get(workspaceKey);
18
+ }
19
+ export function ensureWorkspaceTasksConfig(files, workspaceKey) {
20
+ const cached = configCache.get(workspaceKey);
21
+ if (cached !== undefined)
22
+ return cached;
23
+ const runtime = getWorkspaceRuntime(workspaceKey);
24
+ const loadingEntry = {
25
+ state: { kind: "loading" },
26
+ refreshRequired: runtime.refreshRequired,
27
+ };
28
+ configCache.set(workspaceKey, loadingEntry);
29
+ void enqueueWorkspaceOperation(workspaceKey, async (operation) => {
30
+ const state = await loadWorkspaceTasksConfigSafely(files);
31
+ if (state.kind === "unavailable")
32
+ operation.runtime.refreshRequired = true;
33
+ publishState(operation, state, operation.runtime.refreshRequired);
34
+ }).catch(() => undefined);
35
+ return loadingEntry;
36
+ }
37
+ export function refreshWorkspaceTasksConfig(files, workspaceKey) {
38
+ return enqueueWorkspaceOperation(workspaceKey, async (operation) => {
39
+ const state = await loadWorkspaceTasksConfigSafely(files);
40
+ operation.runtime.refreshRequired = state.kind === "unavailable";
41
+ publishState(operation, state, operation.runtime.refreshRequired);
42
+ return state;
43
+ }, (operation) => {
44
+ if (!isCurrentOperation(operation))
45
+ return;
46
+ configCache.set(workspaceKey, {
47
+ state: { kind: "loading" },
48
+ refreshRequired: operation.runtime.refreshRequired,
49
+ });
50
+ });
51
+ }
52
+ export function guardedWriteWorkspaceTasksConfig(files, workspaceKey, sourceSnapshot, nextConfig) {
53
+ return enqueueWorkspaceOperation(workspaceKey, async (operation) => {
54
+ if (operation.runtime.refreshRequired) {
55
+ return blockMutation(operation, "A successful Refresh is required before another workspace task mutation.");
56
+ }
57
+ const preflight = await loadWorkspaceTasksConfigSafely(files);
58
+ if (preflight.kind === "unavailable") {
59
+ return blockMutation(operation, unavailableDetail(preflight));
60
+ }
61
+ const currentSnapshot = preflight.snapshot;
62
+ if (!snapshotsEqual(sourceSnapshot, currentSnapshot)) {
63
+ return blockMutation(operation, `The ${TASKS_CONFIG_PATH} file changed outside this panel. Refresh before trying again.`, "conflict");
64
+ }
65
+ const payload = serializeWorkspaceTasksConfig(nextConfig);
66
+ try {
67
+ await files.writeFile(TASKS_CONFIG_PATH, payload);
68
+ }
69
+ catch (error) {
70
+ return blockMutation(operation, `Unable to write ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`, "write-failed");
71
+ }
72
+ const postWrite = await loadWorkspaceTasksConfigSafely(files);
73
+ if (postWrite.kind === "loaded" && postWrite.snapshot.content === payload) {
74
+ operation.runtime.refreshRequired = false;
75
+ publishState(operation, postWrite, false);
76
+ return { kind: "written", state: postWrite };
77
+ }
78
+ const detail = postWriteReloadDetail(postWrite);
79
+ blockMutation(operation, detail, undefined, postWrite);
80
+ return { kind: "written-but-unreloaded", detail };
81
+ });
82
+ }
83
+ export function subscribeWorkspaceTasksConfig(listener) {
84
+ subscribers.add(listener);
85
+ return () => {
86
+ subscribers.delete(listener);
87
+ };
88
+ }
89
+ export function clearWorkspaceTasksStateForTesting() {
90
+ stateEpoch += 1;
91
+ configCache.clear();
92
+ workspaceRuntimes.clear();
93
+ }
94
+ async function readCurrentWorkspaceTasksConfig(files) {
9
95
  let file;
10
96
  try {
11
97
  file = await files.readFile(TASKS_CONFIG_PATH);
12
98
  }
13
99
  catch (error) {
14
- if (errorMessage(error) === missingWorkspaceFileError)
100
+ if (isMissingWorkspaceFileError(error))
15
101
  return missing();
16
102
  return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
17
103
  }
@@ -20,15 +106,107 @@ export async function loadWorkspaceTasksConfig(files) {
20
106
  if (file.truncated)
21
107
  return unavailable(`${TASKS_CONFIG_PATH} is too large and was truncated`);
22
108
  const result = parseTasksConfigText(file.content);
23
- if (!result.ok)
24
- return unavailable(result.error);
25
- return { kind: "loaded", config: result.config, path: TASKS_CONFIG_PATH };
109
+ if (!result.ok) {
110
+ return {
111
+ kind: "invalid",
112
+ message: tasksConfigInvalidMessage,
113
+ hint: tasksConfigInvalidHint,
114
+ detail: result.error,
115
+ snapshot: { kind: "text", content: file.content },
116
+ };
117
+ }
118
+ return {
119
+ kind: "loaded",
120
+ config: result.config,
121
+ path: TASKS_CONFIG_PATH,
122
+ snapshot: { kind: "text", content: file.content },
123
+ };
124
+ }
125
+ async function loadWorkspaceTasksConfigSafely(files) {
126
+ try {
127
+ return await readCurrentWorkspaceTasksConfig(files);
128
+ }
129
+ catch (error) {
130
+ return unavailable(`Unable to read ${TASKS_CONFIG_PATH}: ${formatUnknownError(error)}`);
131
+ }
132
+ }
133
+ function enqueueWorkspaceOperation(workspaceKey, operation, onRequest) {
134
+ const runtime = getWorkspaceRuntime(workspaceKey);
135
+ const context = {
136
+ key: workspaceKey,
137
+ runtime,
138
+ generation: runtime.requestGeneration + 1,
139
+ epoch: stateEpoch,
140
+ };
141
+ runtime.requestGeneration = context.generation;
142
+ onRequest?.(context);
143
+ const result = runtime.tail.then(() => operation(context));
144
+ runtime.tail = result.then(() => undefined, () => undefined);
145
+ return result;
146
+ }
147
+ function getWorkspaceRuntime(workspaceKey) {
148
+ const existing = workspaceRuntimes.get(workspaceKey);
149
+ if (existing !== undefined)
150
+ return existing;
151
+ const runtime = {
152
+ requestGeneration: 0,
153
+ refreshRequired: false,
154
+ tail: Promise.resolve(),
155
+ };
156
+ workspaceRuntimes.set(workspaceKey, runtime);
157
+ return runtime;
158
+ }
159
+ function publishState(operation, state, refreshRequired) {
160
+ if (!isCurrentOperation(operation))
161
+ return;
162
+ configCache.set(operation.key, { state, refreshRequired });
163
+ notifySubscribers(operation.key);
164
+ }
165
+ function blockMutation(operation, detail, kind = "preflight-unavailable", postWriteState) {
166
+ operation.runtime.refreshRequired = true;
167
+ if (isCurrentOperation(operation)) {
168
+ const existing = configCache.get(operation.key);
169
+ if (postWriteState !== undefined) {
170
+ configCache.set(operation.key, { state: postWriteState, refreshRequired: true });
171
+ }
172
+ else if (existing !== undefined) {
173
+ configCache.set(operation.key, { ...existing, refreshRequired: true });
174
+ }
175
+ notifySubscribers(operation.key);
176
+ }
177
+ return { kind, detail };
178
+ }
179
+ function isCurrentOperation(operation) {
180
+ return operation.epoch === stateEpoch
181
+ && workspaceRuntimes.get(operation.key) === operation.runtime
182
+ && operation.runtime.requestGeneration === operation.generation;
183
+ }
184
+ function notifySubscribers(workspaceKey) {
185
+ for (const listener of subscribers)
186
+ listener(workspaceKey);
187
+ }
188
+ function snapshotsEqual(left, right) {
189
+ if (left.kind !== right.kind)
190
+ return false;
191
+ if (left.kind === "missing" || right.kind === "missing")
192
+ return true;
193
+ return left.content === right.content;
194
+ }
195
+ function postWriteReloadDetail(result) {
196
+ if (result.kind === "loaded")
197
+ return `Unable to reload ${TASKS_CONFIG_PATH}: the post-write snapshot did not match the canonical payload.`;
198
+ if (result.kind === "missing")
199
+ return `Unable to reload ${TASKS_CONFIG_PATH}: the file is missing after the write.`;
200
+ if (result.kind === "invalid")
201
+ return `Unable to reload ${TASKS_CONFIG_PATH}: ${result.detail}`;
202
+ return `Unable to reload ${TASKS_CONFIG_PATH}: ${unavailableDetail(result)}`;
26
203
  }
27
204
  function missing() {
28
205
  return {
29
206
  kind: "missing",
30
207
  message: tasksConfigMissingMessage,
31
208
  hint: tasksConfigMissingHint,
209
+ snapshot: { kind: "missing" },
32
210
  };
33
211
  }
34
212
  function unavailable(detail) {
@@ -39,8 +217,25 @@ function unavailable(detail) {
39
217
  detail,
40
218
  };
41
219
  }
42
- function errorMessage(error) {
43
- return error instanceof Error ? error.message : undefined;
220
+ function unavailableDetail(result) {
221
+ return result.detail ?? tasksConfigUnavailableMessage;
222
+ }
223
+ function isMissingWorkspaceFileError(error) {
224
+ const message = errorField(error, "message");
225
+ const code = errorField(error, "code");
226
+ return message === "Path does not exist"
227
+ || code?.toLowerCase() === "enoent"
228
+ || message?.toLowerCase() === "enoent"
229
+ || message?.toLowerCase().includes("no such file or directory") === true
230
+ || code?.toLowerCase().includes("no such file or directory") === true;
231
+ }
232
+ function errorField(error, field) {
233
+ if (typeof error === "object" && error !== null && hasStringField(error, field))
234
+ return error[field];
235
+ return error instanceof Error && field === "message" ? error.message : undefined;
236
+ }
237
+ function hasStringField(value, field) {
238
+ return field in value && typeof Reflect.get(value, field) === "string";
44
239
  }
45
240
  function formatUnknownError(error) {
46
241
  return error instanceof Error ? error.message : String(error);
package/docs/plugins.md CHANGED
@@ -301,8 +301,6 @@ Configure workspace tasks in `.pi-webui/tasks.json`:
301
301
  }
302
302
  ```
303
303
 
304
- Open a workspace, choose the **Tasks** tab, and click **Run** next to a task. Commands run in the workspace root because PI WEBUI creates the terminal for that workspace.
305
-
306
304
  Task fields:
307
305
 
308
306
  - `version`: must be `1`.
@@ -314,6 +312,48 @@ Task fields:
314
312
  - `group`: optional group heading.
315
313
  - `confirm`: optional boolean. When true, the browser asks before dispatching the command.
316
314
 
315
+ Open a workspace, choose the **Tasks** tab, and use the editor to manage tasks in the browser:
316
+
317
+ - **Add** creates a task and appends it to the task array.
318
+ - **Edit** updates the task at its existing position in the array.
319
+ - **Delete** removes the selected task without reordering the remaining tasks.
320
+ - **Reset** is offered only for a complete, readable task-file snapshot whose text is invalid, including parse- or schema-invalid text. It is unavailable for binary, truncated, or otherwise unavailable files. After confirmation, it replaces the file with the canonical empty version-1 configuration `{"version":1,"tasks":[]}`.
321
+
322
+ Browser saves write canonical JSON for the supported task fields. They preserve supported task values and task-array order, but canonicalize whitespace and key order and drop unsupported fields; original JSON formatting is not preserved.
323
+
324
+ For a multiline script, encode line feeds as `\n` in JSON:
325
+
326
+ ```json
327
+ {
328
+ "version": 1,
329
+ "tasks": [
330
+ {
331
+ "id": "verify",
332
+ "title": "Build and test",
333
+ "command": "set -e\nnpm run build\nnpm test"
334
+ }
335
+ ]
336
+ }
337
+ ```
338
+
339
+ Click **Run** next to a task to send one terminal request. The server starts one dedicated terminal in the workspace root with `$SHELL -lc`, so lines in a multiline script share shell state such as variables and the current directory. The shell's final exit status determines whether the run succeeds or fails. For POSIX-compatible fail-fast behavior, use syntax such as:
340
+
341
+ ```sh
342
+ set -e
343
+ npm run build
344
+ npm test
345
+ ```
346
+
347
+ or:
348
+
349
+ ```sh
350
+ npm run build && npm test
351
+ ```
352
+
353
+ These are POSIX-compatible examples, not shell-neutral guarantees.
354
+
355
+ If the task file changed after the browser loaded it, the editor refuses the stale save as a best-effort conflict guard instead of merging the changes. Use **Refresh** to load the latest file, review it, and retry. This protects against sequential stale browser edits; it is not an atomic cross-tab or cross-process compare-and-swap guarantee.
356
+
317
357
  Review task configs before running them, especially in shared projects. Workspace Tasks runs trusted shell commands from your repositories.
318
358
 
319
359
  ## Discovery and packaging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperdreamer/pi-webui",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
5
5
  "license": "MIT",
6
6
  "author": "Federico Jaramillo Martinez and HyperDreamer",