pi-webdesk 0.1.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.
Files changed (64) hide show
  1. package/README.md +111 -0
  2. package/dist/apps/daemon/src/appearance-preferences.js +218 -0
  3. package/dist/apps/daemon/src/auth.js +88 -0
  4. package/dist/apps/daemon/src/bin.js +123 -0
  5. package/dist/apps/daemon/src/cli.js +48 -0
  6. package/dist/apps/daemon/src/event-hub.js +155 -0
  7. package/dist/apps/daemon/src/index.js +102 -0
  8. package/dist/apps/daemon/src/launcher-control.js +114 -0
  9. package/dist/apps/daemon/src/launcher.js +73 -0
  10. package/dist/apps/daemon/src/pi-auth.js +290 -0
  11. package/dist/apps/daemon/src/pi-resources.js +182 -0
  12. package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
  13. package/dist/apps/daemon/src/pi-sessions.js +265 -0
  14. package/dist/apps/daemon/src/runtime-process.js +241 -0
  15. package/dist/apps/daemon/src/secret.js +71 -0
  16. package/dist/apps/daemon/src/server.js +1662 -0
  17. package/dist/apps/daemon/src/session-projection.js +117 -0
  18. package/dist/apps/daemon/src/state-lock.js +31 -0
  19. package/dist/apps/daemon/src/static-web.js +53 -0
  20. package/dist/apps/daemon/src/task-archive.js +152 -0
  21. package/dist/apps/daemon/src/task-commit.js +503 -0
  22. package/dist/apps/daemon/src/task-merge.js +912 -0
  23. package/dist/apps/daemon/src/task-review.js +204 -0
  24. package/dist/apps/daemon/src/task-runtime.js +1124 -0
  25. package/dist/apps/daemon/src/task-validation.js +352 -0
  26. package/dist/apps/daemon/src/workspace-store.js +140 -0
  27. package/dist/apps/daemon/src/workspace.js +795 -0
  28. package/dist/extensions/webdesk.js +34 -0
  29. package/dist/packages/git/src/commit.js +675 -0
  30. package/dist/packages/git/src/errors.js +55 -0
  31. package/dist/packages/git/src/fingerprint.js +286 -0
  32. package/dist/packages/git/src/index.js +123 -0
  33. package/dist/packages/git/src/merge.js +1008 -0
  34. package/dist/packages/git/src/paths.js +58 -0
  35. package/dist/packages/git/src/repository.js +77 -0
  36. package/dist/packages/git/src/review.js +396 -0
  37. package/dist/packages/git/src/runner.js +110 -0
  38. package/dist/packages/git/src/validation.js +263 -0
  39. package/dist/packages/git/src/worktree.js +233 -0
  40. package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
  41. package/dist/packages/pi-bridge/src/auth.js +80 -0
  42. package/dist/packages/pi-bridge/src/errors.js +19 -0
  43. package/dist/packages/pi-bridge/src/handshake.js +43 -0
  44. package/dist/packages/pi-bridge/src/index.js +76 -0
  45. package/dist/packages/pi-bridge/src/jsonl.js +105 -0
  46. package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
  47. package/dist/packages/pi-bridge/src/resolve.js +59 -0
  48. package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
  49. package/dist/packages/pi-bridge/src/resources.js +481 -0
  50. package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
  51. package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
  52. package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
  53. package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
  54. package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
  55. package/dist/packages/pi-bridge/src/runtime.js +0 -0
  56. package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
  57. package/dist/packages/pi-bridge/src/sessions.js +314 -0
  58. package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
  59. package/dist/packages/protocol/src/index.js +1863 -0
  60. package/dist/web/assets/index-BOw_fhvO.css +2 -0
  61. package/dist/web/assets/index-oXs7yAAo.js +119 -0
  62. package/dist/web/index.html +14 -0
  63. package/package.json +69 -0
  64. package/scripts/prepare.mjs +7 -0
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Pi Webdesk
2
+
3
+ An opinionated web UI for Pi.
4
+
5
+ Webdesk is a local-first browser workspace built specifically for [Pi](https://pi.dev/). It gives a solo developer a calm, worktree-first place to run several coding tasks, inspect changes, validate them, and explicitly decide what gets committed or merged.
6
+
7
+ Pi remains the agent runtime and the source of truth for sessions, models, authentication, settings, skills, extensions, prompts, themes, and packages.
8
+
9
+ ## Status
10
+
11
+ The core vertical slice is runnable locally, including through Pi's package manager: an authenticated localhost browser can register repositories, create isolated task worktrees, run supervised Pi runtimes with policy approvals, stream and steer prompts, and reconnect with exact event replay or a Pi-reconstructed canonical snapshot. Tasks provide live bounded Git review, explicit validation, snapshot commit approval, local merge approval, and non-destructive archive/restore. Durable runtime ownership lets a restarted daemon terminate verified orphan process groups before accepting traffic; ambiguous ownership blocks only the affected task, and interrupted prompts are never resumed automatically. The current UI includes formatted conversations, expandable tool and skill-file activity with canonical reload recovery, proposed edit previews, compact session branches, persistent task selection, read-only Pi configuration/resource discovery, and schema-bound Webdesk appearance preferences.
12
+
13
+ - [Product brief](docs/product-brief.md)
14
+ - [Architecture](docs/architecture.md)
15
+ - [Roadmap](ROADMAP.md)
16
+ - [Runtime boundary decision](docs/decisions/0001-pi-runtime-boundary.md)
17
+
18
+ ## Install in Pi
19
+
20
+ Install the public preview through Pi's package manager:
21
+
22
+ Requires Node.js **22.19+**, npm, Git **2.38+**, and an existing Pi setup. macOS is the primary platform; Linux uses `xdg-open` when available.
23
+
24
+ ```sh
25
+ pi install npm:pi-webdesk
26
+ ```
27
+
28
+ Restart Pi, or run `/reload` in an existing interactive session. Then:
29
+
30
+ ```text
31
+ /webdesk Start Webdesk in the background and open its browser workspace
32
+ /webdesk status Show whether the daemon is running
33
+ /webdesk stop Gracefully stop the daemon and its active task runtimes
34
+ ```
35
+
36
+ The npm package includes the compiled browser app and daemon. Pi loads only the launcher extension; loading or reloading it never starts a daemon. `/webdesk` opens a fresh one-time login link to `http://127.0.0.1:4517` (valid for 15 minutes). If the browser cannot open automatically, copy the link from Pi. Repeating `/webdesk` reuses the running daemon and replaces any unused login link.
37
+
38
+ The daemon keeps running when Pi or the browser closes. `/webdesk stop` preserves task worktrees, metadata, and Pi sessions; it stops active execution without replaying interrupted prompts. Use it before updating or removing the package, then restart Pi or `/reload` to load the updated launcher. Removing the Pi package does not remove `~/.pita` or Pi's files. Webdesk opens its workspace; your current terminal conversation is not moved automatically. Existing sessions can be continued through Webdesk's session workflow.
39
+
40
+ After stopping Webdesk, update it from your terminal:
41
+
42
+ ```sh
43
+ pi update npm:pi-webdesk
44
+ ```
45
+
46
+ Then restart Pi or `/reload`, and run `/webdesk` again. Package updates are managed by Pi; Webdesk's resource UI is read-only.
47
+
48
+ You can also install the current GitHub source with `pi install https://github.com/luka-zivkovic/pi-webdesk`. This runs `npm install --omit=dev` and builds the package automatically. If switching from a Git installation to npm, stop Webdesk and run `pi remove https://github.com/luka-zivkovic/pi-webdesk` before the npm install to avoid duplicate launcher extensions. Your worktrees and sessions are preserved.
49
+
50
+ Configuration is inherited from the environment that starts Pi:
51
+
52
+ | Variable | Default | Purpose |
53
+ | --- | --- | --- |
54
+ | `PITA_PORT` | `4517` | Loopback HTTP port |
55
+ | `PITA_STATE_DIR` | `~/.pita` | Private state directory, worktrees, and background log (`daemon.log`) |
56
+ | `PITA_PI_EXECUTABLE` | Package's tested Pi runtime | Explicit Pi executable override |
57
+
58
+ Webdesk keeps its tested Pi runtime dependency alongside the application and uses Pi's existing configuration, authentication, resources, and session formats. Set Pi's own `PI_CODING_AGENT_DIR` to use a different Pi configuration directory. Binding to other interfaces is not supported.
59
+
60
+ To install a local development checkout:
61
+
62
+ ```sh
63
+ pnpm install
64
+ pnpm build
65
+ pi install "$PWD"
66
+ ```
67
+
68
+ The distribution also includes a `pi-webdesk` executable for npm installations. From a built checkout, run `node dist/apps/daemon/src/cli.js --no-browser` to serve the UI in the foreground; Ctrl+C stops it. `open`, `status`, and `stop` are also available as CLI commands. Pi package installation does not add this executable to your shell's global PATH.
69
+
70
+ ### Existing Pita installations
71
+
72
+ Webdesk was previously named Pita. `/pita` remains an alias for `/webdesk`. The existing `~/.pita` data directory and `PITA_*` environment variables retain their names, so tasks, worktrees, sessions, preferences, and launch configuration continue to work without moving data. Existing browser storage and internal protocol identifiers are also preserved.
73
+
74
+ ## Development and verification
75
+
76
+ ```sh
77
+ pnpm install
78
+ pnpm --filter @pita/daemon start # terminal 1
79
+ pnpm --filter @pita/web dev # terminal 2
80
+ ```
81
+
82
+ The development daemon prints a login link for the Vite origin, `http://localhost:5173`. `PITA_ALLOWED_ORIGIN` configures that development origin; `PITA_DAEMON_PORT` configures Vite's proxy when using a different daemon port.
83
+
84
+ ```sh
85
+ pnpm test
86
+ pnpm typecheck
87
+ pnpm build
88
+ pnpm test:package
89
+ ```
90
+
91
+ The package check performs a fresh npm source install, packs and installs production dependencies into an isolated directory, registers the package with an isolated Pi configuration, verifies the launcher and real Pi policy handshake, and checks browser authentication and daemon restart. It makes no model calls. `npm pack` rebuilds the distribution. Only the root `pi-webdesk` package is published; internal workspace packages stay private.
92
+
93
+ For a release, run the checks above plus `pnpm test:process`, then pack and test the exact archive before publishing it:
94
+
95
+ ```sh
96
+ npm pack
97
+ WEBDESK_PACKAGE_TARBALL="$PWD/pi-webdesk-0.1.0.tgz" pnpm test:package
98
+ npm publish ./pi-webdesk-0.1.0.tgz --access public --ignore-scripts
99
+ ```
100
+
101
+ Use the version from `package.json` in the archive name. `WEBDESK_PACKAGE_TARBALL` makes the package check install that archive while still verifying a fresh Git-source build.
102
+
103
+ ## Locked technology direction
104
+
105
+ - Browser UI: React, TypeScript, Vite
106
+ - Styling and components: Tailwind CSS v4, shadcn/ui on Base UI, Lucide icons
107
+ - Local service: Node.js/TypeScript daemon bound to localhost
108
+ - Runtime: one supervised Pi runtime per active task
109
+ - Repository model: one Git worktree per task
110
+
111
+ The first implementation milestone is a thin vertical slice: open a repository, create a task worktree, talk to Pi with streamed events, survive a browser reconnect, and inspect the resulting diff.
@@ -0,0 +1,218 @@
1
+ // apps/daemon/src/appearance-preferences.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { copyFile, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { z } from "zod";
6
+ import {
7
+ PITA_PREFERENCES_SCHEMA_VERSION,
8
+ PROTOCOL_VERSION,
9
+ pitaAppearancePreferencesSchema,
10
+ pitaPreferencesSnapshotSchema
11
+ } from "../../../packages/protocol/src/index.js";
12
+ var PITA_PREFERENCES_FILE = "preferences-v1.json";
13
+ var STALE_TEMP_FILE = /^\.preferences-v1\.json\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
14
+ var MAX_REPOSITORY_PREFERENCES = 1e3;
15
+ var DEFAULT_PITA_APPEARANCE = {
16
+ palette: "warm-sand",
17
+ mode: "system",
18
+ density: "comfortable",
19
+ sidebarWidth: 264,
20
+ sidebarVisible: true,
21
+ contextPanel: "right"
22
+ };
23
+ var preferencesDocumentSchema = z.object({
24
+ schemaVersion: z.literal(PITA_PREFERENCES_SCHEMA_VERSION),
25
+ global: pitaAppearancePreferencesSchema,
26
+ repositories: z.record(z.uuid(), pitaAppearancePreferencesSchema)
27
+ }).strict().refine(
28
+ (document) => Object.keys(document.repositories).length <= MAX_REPOSITORY_PREFERENCES,
29
+ "Too many repository appearance overrides."
30
+ );
31
+ var PitaPreferencesOperationError = class extends Error {
32
+ name = "PitaPreferencesOperationError";
33
+ code;
34
+ status;
35
+ constructor(code, message, status) {
36
+ super(message);
37
+ this.code = code;
38
+ this.status = status;
39
+ }
40
+ };
41
+ function initialDocument() {
42
+ return {
43
+ schemaVersion: PITA_PREFERENCES_SCHEMA_VERSION,
44
+ global: { ...DEFAULT_PITA_APPEARANCE },
45
+ repositories: {}
46
+ };
47
+ }
48
+ function errorCode(error) {
49
+ return error !== null && typeof error === "object" && "code" in error ? String(error.code) : void 0;
50
+ }
51
+ function createPitaPreferencesService(options) {
52
+ const stateDir = path.resolve(options.stateDir);
53
+ const filePath = path.join(stateDir, PITA_PREFERENCES_FILE);
54
+ let document = null;
55
+ let initialization = null;
56
+ let mutationQueue = Promise.resolve();
57
+ let invalidDocument = false;
58
+ async function persist(next) {
59
+ await mkdir(stateDir, { recursive: true, mode: 448 });
60
+ const tempPath = path.join(
61
+ stateDir,
62
+ `.${PITA_PREFERENCES_FILE}.${process.pid}.${randomUUID()}.tmp`
63
+ );
64
+ let handle = null;
65
+ try {
66
+ handle = await open(tempPath, "wx", 384);
67
+ await handle.writeFile(`${JSON.stringify(next, null, 2)}
68
+ `, "utf8");
69
+ await handle.sync();
70
+ await handle.close();
71
+ handle = null;
72
+ await rename(tempPath, filePath);
73
+ let directory = null;
74
+ try {
75
+ directory = await open(stateDir, "r");
76
+ await directory.sync();
77
+ } catch {
78
+ } finally {
79
+ await directory?.close().catch(() => void 0);
80
+ }
81
+ } catch (error) {
82
+ await handle?.close().catch(() => void 0);
83
+ await unlink(tempPath).catch(() => void 0);
84
+ throw new PitaPreferencesOperationError(
85
+ "preferences-unavailable",
86
+ `Could not persist Webdesk appearance preferences: ${errorCode(error) ?? "write failed"}.`,
87
+ 503
88
+ );
89
+ }
90
+ }
91
+ async function load() {
92
+ await mkdir(stateDir, { recursive: true, mode: 448 });
93
+ for (const name of await readdir(stateDir)) {
94
+ if (STALE_TEMP_FILE.test(name)) {
95
+ await unlink(path.join(stateDir, name)).catch(() => void 0);
96
+ }
97
+ }
98
+ let raw;
99
+ try {
100
+ raw = await readFile(filePath, "utf8");
101
+ } catch (error) {
102
+ if (errorCode(error) === "ENOENT") {
103
+ const initial = initialDocument();
104
+ await persist(initial);
105
+ document = initial;
106
+ return;
107
+ }
108
+ throw new PitaPreferencesOperationError(
109
+ "preferences-unavailable",
110
+ "Webdesk appearance preferences could not be read. The existing file was left untouched.",
111
+ 503
112
+ );
113
+ }
114
+ try {
115
+ document = preferencesDocumentSchema.parse(JSON.parse(raw));
116
+ } catch {
117
+ invalidDocument = true;
118
+ throw new PitaPreferencesOperationError(
119
+ "preferences-unavailable",
120
+ "Webdesk appearance preferences are invalid. The existing file was preserved for recovery.",
121
+ 503
122
+ );
123
+ }
124
+ }
125
+ async function read() {
126
+ initialization ??= load();
127
+ await initialization;
128
+ return preferencesDocumentSchema.parse(document);
129
+ }
130
+ function toSnapshot(current, repositoryId) {
131
+ const repository = repositoryId === null ? null : current.repositories[repositoryId] ?? null;
132
+ return pitaPreferencesSnapshotSchema.parse({
133
+ protocol: PROTOCOL_VERSION,
134
+ type: "pita-preferences-snapshot",
135
+ schemaVersion: PITA_PREFERENCES_SCHEMA_VERSION,
136
+ repositoryId,
137
+ global: current.global,
138
+ repository,
139
+ effective: repository ?? current.global
140
+ });
141
+ }
142
+ async function update(request) {
143
+ const operation = mutationQueue.then(async () => {
144
+ let current;
145
+ try {
146
+ current = await read();
147
+ } catch (error) {
148
+ if (!invalidDocument || request.scope !== "global" || request.action !== "reset") {
149
+ throw error;
150
+ }
151
+ const recoveryPath = path.join(
152
+ stateDir,
153
+ `${PITA_PREFERENCES_FILE}.invalid.${Date.now()}.${randomUUID()}.json`
154
+ );
155
+ try {
156
+ await copyFile(filePath, recoveryPath, 0);
157
+ } catch (copyError) {
158
+ throw new PitaPreferencesOperationError(
159
+ "preferences-unavailable",
160
+ `Could not preserve the invalid appearance file for recovery: ${errorCode(copyError) ?? "copy failed"}.`,
161
+ 503
162
+ );
163
+ }
164
+ const recovered = initialDocument();
165
+ await persist(recovered);
166
+ document = recovered;
167
+ invalidDocument = false;
168
+ initialization = Promise.resolve();
169
+ return toSnapshot(recovered, request.repositoryId);
170
+ }
171
+ const repositories = { ...current.repositories };
172
+ let global = current.global;
173
+ if (request.scope === "global") {
174
+ global = request.action === "reset" ? { ...DEFAULT_PITA_APPEARANCE } : pitaAppearancePreferencesSchema.parse(request.preferences);
175
+ } else {
176
+ const repositoryId = request.repositoryId;
177
+ if (repositoryId === null) {
178
+ throw new PitaPreferencesOperationError(
179
+ "invalid-request",
180
+ "Choose a repository before changing repository appearance.",
181
+ 400
182
+ );
183
+ }
184
+ if (request.action === "reset") {
185
+ delete repositories[repositoryId];
186
+ } else {
187
+ repositories[repositoryId] = pitaAppearancePreferencesSchema.parse(request.preferences);
188
+ }
189
+ }
190
+ const next = preferencesDocumentSchema.parse({
191
+ schemaVersion: PITA_PREFERENCES_SCHEMA_VERSION,
192
+ global,
193
+ repositories
194
+ });
195
+ await persist(next);
196
+ document = next;
197
+ return toSnapshot(next, request.repositoryId);
198
+ });
199
+ mutationQueue = operation.then(
200
+ () => void 0,
201
+ () => void 0
202
+ );
203
+ return operation;
204
+ }
205
+ return {
206
+ filePath,
207
+ async snapshot(repositoryId) {
208
+ return toSnapshot(await read(), repositoryId);
209
+ },
210
+ update
211
+ };
212
+ }
213
+ export {
214
+ DEFAULT_PITA_APPEARANCE,
215
+ PITA_PREFERENCES_FILE,
216
+ PitaPreferencesOperationError,
217
+ createPitaPreferencesService
218
+ };
@@ -0,0 +1,88 @@
1
+ // apps/daemon/src/auth.ts
2
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { z } from "zod";
4
+ var SESSION_COOKIE_NAME = "pita_session";
5
+ var TOKEN_VERSION = "v1";
6
+ var MAC_CONTEXT = `pita-session-${TOKEN_VERSION}.`;
7
+ var sessionClaimsSchema = z.object({
8
+ sid: z.string().min(1),
9
+ iat: z.number().int().min(0),
10
+ exp: z.number().int().min(0)
11
+ });
12
+ function generateBootstrapToken() {
13
+ return randomBytes(32).toString("hex");
14
+ }
15
+ function tokensMatch(expected, provided) {
16
+ const a = createHash("sha256").update(expected, "utf8").digest();
17
+ const b = createHash("sha256").update(provided, "utf8").digest();
18
+ return timingSafeEqual(a, b);
19
+ }
20
+ function sign(secret, payload) {
21
+ return createHmac("sha256", secret).update(MAC_CONTEXT + payload, "utf8").digest("base64url");
22
+ }
23
+ function createSessionToken(secret, init) {
24
+ const expiresAt = init.nowMs + init.ttlMs;
25
+ const payload = Buffer.from(
26
+ JSON.stringify({
27
+ sid: randomBytes(16).toString("hex"),
28
+ iat: init.nowMs,
29
+ exp: expiresAt
30
+ }),
31
+ "utf8"
32
+ ).toString("base64url");
33
+ return {
34
+ token: `${TOKEN_VERSION}.${payload}.${sign(secret, payload)}`,
35
+ expiresAt
36
+ };
37
+ }
38
+ function verifySessionToken(secret, token, nowMs) {
39
+ const parts = token.split(".");
40
+ if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) {
41
+ return null;
42
+ }
43
+ const [, payload, mac] = parts;
44
+ if (payload === void 0 || mac === void 0) {
45
+ return null;
46
+ }
47
+ const expected = sign(secret, payload);
48
+ const macBuffer = Buffer.from(mac, "utf8");
49
+ const expectedBuffer = Buffer.from(expected, "utf8");
50
+ if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) {
51
+ return null;
52
+ }
53
+ let claims;
54
+ try {
55
+ claims = sessionClaimsSchema.parse(
56
+ JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
57
+ );
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (claims.exp <= nowMs) {
62
+ return null;
63
+ }
64
+ return { sid: claims.sid, expiresAt: claims.exp };
65
+ }
66
+ function readCookie(header, name) {
67
+ if (header === void 0) {
68
+ return null;
69
+ }
70
+ for (const part of header.split(";")) {
71
+ const separator = part.indexOf("=");
72
+ if (separator === -1) {
73
+ continue;
74
+ }
75
+ if (part.slice(0, separator).trim() === name) {
76
+ return part.slice(separator + 1).trim();
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ export {
82
+ SESSION_COOKIE_NAME,
83
+ createSessionToken,
84
+ generateBootstrapToken,
85
+ readCookie,
86
+ tokensMatch,
87
+ verifySessionToken
88
+ };
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+
3
+ // apps/daemon/src/bin.ts
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { accessSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createDaemonServer, DaemonConfigError } from "./server.js";
9
+ import { DaemonStateError } from "./secret.js";
10
+ import { DaemonStateLockError } from "./state-lock.js";
11
+ import { RuntimeProcessRecoveryError } from "./runtime-process.js";
12
+ import { WorkspaceStateError } from "./workspace-store.js";
13
+ import { PitaPreferencesOperationError } from "./appearance-preferences.js";
14
+ import { listenLauncherControl } from "./launcher-control.js";
15
+ import { openBrowser } from "./launcher.js";
16
+ var DEFAULT_PORT = 4517;
17
+ var DEFAULT_ORIGIN = "http://localhost:5173";
18
+ function readPort(raw) {
19
+ if (raw === void 0 || raw === "") {
20
+ return DEFAULT_PORT;
21
+ }
22
+ const port = Number(raw);
23
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
24
+ throw new DaemonConfigError(`PITA_PORT must be an integer in 1-65535, got ${raw}`);
25
+ }
26
+ return port;
27
+ }
28
+ function readOrigin(raw, fallback = DEFAULT_ORIGIN) {
29
+ if (raw === void 0 || raw === "") {
30
+ return fallback;
31
+ }
32
+ let url;
33
+ try {
34
+ url = new URL(raw);
35
+ } catch {
36
+ throw new DaemonConfigError(`PITA_ALLOWED_ORIGIN must be a URL origin, got ${raw}`);
37
+ }
38
+ if (url.origin !== raw) {
39
+ throw new DaemonConfigError(
40
+ `PITA_ALLOWED_ORIGIN must be exactly an origin (no path), e.g. ${url.origin}`
41
+ );
42
+ }
43
+ return url.origin;
44
+ }
45
+ async function runDaemon(options = {}) {
46
+ const stateDir = path.resolve(
47
+ process.env.PITA_STATE_DIR ?? path.join(os.homedir(), ".pita")
48
+ );
49
+ const port = readPort(process.env.PITA_PORT);
50
+ const allowedOrigin = readOrigin(process.env.PITA_ALLOWED_ORIGIN, options.packaged ? `http://127.0.0.1:${port}` : DEFAULT_ORIGIN);
51
+ const webDirectory = options.packaged ? fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../../../dist/web/" : "../../../web/", import.meta.url)) : void 0;
52
+ if (webDirectory) accessSync(path.join(webDirectory, "index.html"));
53
+ const server = createDaemonServer({
54
+ stateDir,
55
+ allowedOrigin,
56
+ port,
57
+ ...webDirectory === void 0 ? {} : { webDirectory }
58
+ });
59
+ const bound = await server.listen();
60
+ const printBootstrapLink = (token) => {
61
+ process.stderr.write(
62
+ `open this one-time link in your browser (valid for 15 minutes):
63
+ ${allowedOrigin}/?pita_bootstrap=${token}
64
+ `
65
+ );
66
+ };
67
+ process.stderr.write(
68
+ `Webdesk daemon listening on http://${bound.address}:${bound.port} (state: ${stateDir})
69
+ run \`kill -USR2 ${process.pid}\` to print a fresh one-time link
70
+ `
71
+ );
72
+ let closing = false;
73
+ let closeControl;
74
+ const shutdown = () => {
75
+ if (closing) {
76
+ return;
77
+ }
78
+ closing = true;
79
+ void (async () => {
80
+ try {
81
+ await closeControl?.();
82
+ } finally {
83
+ await server.close();
84
+ }
85
+ })().then(
86
+ () => process.exit(0),
87
+ () => process.exit(1)
88
+ );
89
+ };
90
+ try {
91
+ closeControl = await listenLauncherControl(stateDir, (command) => ({
92
+ version: 1,
93
+ status: command === "stop" ? "stopping" : "running",
94
+ origin: allowedOrigin,
95
+ ...command === "open" ? { token: server.rotateBootstrapToken() } : {}
96
+ }), shutdown);
97
+ } catch (error) {
98
+ await server.close();
99
+ throw error;
100
+ }
101
+ process.on("SIGINT", shutdown);
102
+ process.on("SIGTERM", shutdown);
103
+ process.on("SIGUSR2", () => {
104
+ if (!closing) {
105
+ printBootstrapLink(server.rotateBootstrapToken());
106
+ }
107
+ });
108
+ if (process.env.PITA_BACKGROUND !== "1") {
109
+ printBootstrapLink(server.bootstrapToken);
110
+ if (options.open && !await openBrowser(`${allowedOrigin}/?pita_bootstrap=${server.bootstrapToken}`)) {
111
+ process.stderr.write("Could not open a browser automatically. Use the link above.\n");
112
+ }
113
+ }
114
+ }
115
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) runDaemon().catch((error) => {
116
+ const message = error instanceof DaemonStateError || error instanceof DaemonStateLockError || error instanceof DaemonConfigError || error instanceof RuntimeProcessRecoveryError || error instanceof WorkspaceStateError || error instanceof PitaPreferencesOperationError ? error.message : String(error);
117
+ process.stderr.write(`Webdesk daemon failed to start: ${message}
118
+ `);
119
+ process.exit(1);
120
+ });
121
+ export {
122
+ runDaemon
123
+ };
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ // apps/daemon/src/cli.ts
4
+ import { bootstrapUrl, launchPita, launcherStateDir, openBrowser } from "./launcher.js";
5
+ import { requestLauncherControl } from "./launcher-control.js";
6
+ var HELP = `Usage: pi-webdesk [serve|open|status|stop] [--no-browser]
7
+
8
+ serve Run in this terminal (default); Ctrl+C stops the daemon and runtimes
9
+ open Start a background daemon or open the running workspace
10
+ status Show whether Webdesk is running
11
+ stop Gracefully stop the daemon and its task runtimes
12
+
13
+ Environment: PITA_PORT (4517), PITA_STATE_DIR (~/.pita), PITA_PI_EXECUTABLE
14
+ Webdesk listens only on 127.0.0.1. State and Pi sessions survive stopping.
15
+ `;
16
+ async function main() {
17
+ const args = process.argv.slice(2);
18
+ if (args.includes("--help") || args.includes("-h")) {
19
+ process.stdout.write(HELP);
20
+ return;
21
+ }
22
+ const noBrowser = args.includes("--no-browser");
23
+ const positional = args.filter((arg) => arg !== "--no-browser");
24
+ const command = positional[0] ?? "serve";
25
+ if (positional.length > 1 || !["serve", "open", "status", "stop"].includes(command)) throw new Error(HELP);
26
+ if (command === "serve") {
27
+ const { runDaemon } = await import("./bin.js");
28
+ await runDaemon({ packaged: true, open: !noBrowser });
29
+ return;
30
+ }
31
+ if (command === "open") {
32
+ const reply2 = await launchPita();
33
+ const url = bootstrapUrl(reply2);
34
+ process.stdout.write(`Open this one-time link (valid for 15 minutes):
35
+ ${url}
36
+ `);
37
+ if (!noBrowser && !await openBrowser(url)) process.stderr.write("Could not open a browser automatically. Use the link above.\n");
38
+ return;
39
+ }
40
+ const reply = await requestLauncherControl(launcherStateDir(), command);
41
+ process.stdout.write(reply ? `Webdesk ${reply.status} at ${reply.origin}
42
+ ` : "Webdesk is not running.\n");
43
+ }
44
+ main().catch((error) => {
45
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
46
+ `);
47
+ process.exitCode = 1;
48
+ });