@pi-chamber/web 0.2.0 → 0.2.1

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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/{CommitSection-LewzHfDH.js → CommitSection-BQBmpPVE.js} +1 -1
  3. package/dist/assets/{DiagramEditor-BOIJVnh3.js → DiagramEditor-Cp22TGpq.js} +1 -1
  4. package/dist/assets/{DiagramView-CdNHezna.js → DiagramView-E4HJ4ZPU.js} +1 -1
  5. package/dist/assets/{DiffView-Cmw-RHIH.js → DiffView-d23o9CKP.js} +1 -1
  6. package/dist/assets/{FilesView-BODbL0Xm.js → FilesView-DIDB-xK7.js} +1 -1
  7. package/dist/assets/{GitView-DqYF-n_h.js → GitView-CkUONO1-.js} +3 -3
  8. package/dist/assets/{JsonTreeView-DzEjA1ht.js → JsonTreeView-By2oGMNK.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-Bfq0bOSM.js → MarkdownRendererImpl-DUghE55t.js} +1 -1
  10. package/dist/assets/{PatchDiff-CtVByTC4.js → PatchDiff-BjN6zMtK.js} +1 -1
  11. package/dist/assets/{PierreDiffViewer-Cwoewmiu.js → PierreDiffViewer-B38_7NnU.js} +1 -1
  12. package/dist/assets/{SessionSwitcherDropdown-DNjCJjzF.js → SessionSwitcherDropdown-NbVfnqQQ.js} +102 -102
  13. package/dist/assets/{SettingsView-BtLvHlj4.js → SettingsView-C2dCv0r7.js} +1 -1
  14. package/dist/assets/{SettingsWindow-CPd7JmJs.js → SettingsWindow-DsMhdX5H.js} +1 -1
  15. package/dist/assets/{ToolOutputDialog-CFFC5eI-.js → ToolOutputDialog-DVesARmI.js} +1 -1
  16. package/dist/assets/{ToolPartDiffPreview-yFq1DKSA.js → ToolPartDiffPreview-C2N_oIO-.js} +1 -1
  17. package/dist/assets/{VirtualizedFileDiff-CC5lTPJS.js → VirtualizedFileDiff-B7eVR6Pt.js} +1 -1
  18. package/dist/assets/{collapsible-D_555Hw_.js → collapsible-CPkWB1VR.js} +1 -1
  19. package/dist/assets/{computeVirtualFileMetrics-BcVuV0Ic.js → computeVirtualFileMetrics-BsZulefS.js} +1 -1
  20. package/dist/assets/{contextFileOpenGuard-BygvjHuE.js → contextFileOpenGuard-BmmlK5E3.js} +1 -1
  21. package/dist/assets/{index-DYrJXRkU.css → index-BZXqJcua.css} +1 -1
  22. package/dist/assets/{index-C9CLY-is.js → index-C84KrQoy.js} +1 -1
  23. package/dist/assets/{isDiffPlainText-Bher0UxW.js → isDiffPlainText-DsuyxFXo.js} +1 -1
  24. package/dist/assets/{isFilePlainText-EIViqOlI.js → isFilePlainText-CCYoe7us.js} +1 -1
  25. package/dist/assets/{main-1bIgjP48.js → main-ClYgYVm1.js} +2 -2
  26. package/dist/assets/{main-JDtt2bQS.js → main-CmrF_CaW.js} +4 -4
  27. package/dist/assets/{markdownCore-Bm4FxtK1.js → markdownCore-D2uqals1.js} +1 -1
  28. package/dist/assets/{miniChat-B7kKqKHH.js → miniChat-DmX9SjxM.js} +2 -2
  29. package/dist/assets/{mobile-DZPRce5z.js → mobile-C-PCxN62.js} +2 -2
  30. package/dist/assets/{renderElectronMiniChatApp-Dq0GdThJ.js → renderElectronMiniChatApp-BhXZtZlU.js} +1 -1
  31. package/dist/assets/{renderMobileApp-DlKnjObC.js → renderMobileApp-DUSTAPWw.js} +4 -4
  32. package/dist/assets/{runtimeSurface-B6sKTjo7.js → runtimeSurface-CouGjZ3A.js} +1 -1
  33. package/dist/assets/{useInlineCommentController-CSR_Ju0a.js → useInlineCommentController-buNJ3xB4.js} +1 -1
  34. package/dist/assets/{usePanelSlide-BkonU4LS.js → usePanelSlide-BRwyEEzi.js} +9 -9
  35. package/dist/assets/{useWindowTitle-rt_8xbd9.js → useWindowTitle-CnF4pKz0.js} +1 -1
  36. package/dist/assets/{vendor-codemirror-language-data-Be-zDX0u.js → vendor-codemirror-language-data-qBLdzwMc.js} +2 -2
  37. package/dist/assets/{web-components-DgFD1Jms.js → web-components-BhO08KaW.js} +1 -1
  38. package/dist/index.html +4 -4
  39. package/dist/mini-chat.html +3 -3
  40. package/dist/mobile.html +3 -3
  41. package/package.json +1 -1
  42. package/server/index.js +6 -1
  43. package/server/lib/pi/routes.js +13 -1
  44. package/server/lib/pi/routes.test.js +4 -0
  45. package/server/lib/pi/session-daemon/DOCUMENTATION.md +6 -5
  46. package/server/lib/pi/session-daemon/daemon-process.js +4 -1
  47. package/server/lib/pi/session-daemon/session-daemon.js +138 -20
  48. package/server/lib/pi/session-daemon/session-daemon.test.js +208 -0
  49. package/server/lib/pi/session-daemon/session-jsonl.js +258 -6
  50. package/server/lib/pi/session-daemon/session-jsonl.test.js +69 -1
  51. package/server/lib/pi/session-daemon/supervisor.js +41 -21
  52. package/server/lib/pi/session-daemon/supervisor.test.js +28 -0
  53. /package/dist/assets/{index-DQr8-8RF.js → index-BhXma4dv.js} +0 -0
@@ -7,13 +7,13 @@ This module is the Pi-owned session-daemon foundation. A detached local daemon p
7
7
  ## Entrypoints
8
8
 
9
9
  - `session-daemon.js`
10
- - `createPiSessionRuntime({ cwd, agentDir })` creates a persistent Pi runtime using the selected server-side cwd and Pi agent directory. It uses Pi's normal settings, credentials, models, sessions, skills, prompts, and context discovery, with native extensions explicitly disabled for the core milestone. Sessions created outside PiChamber (Pi CLI or any other writer of the cwd-scoped JSONL directory) are the same records: `sessions.list`/`open` hydrate from `getEntries()`, and a later prompt must overlay live turns onto that log rather than replace it.
10
+ - `createPiSessionRuntime({ cwd, agentDir })` creates a persistent Pi runtime using the selected server-side cwd and Pi agent directory. It uses Pi's normal settings, credentials, models, sessions, skills, prompts, and context discovery, with native extensions explicitly disabled for the core milestone. Sessions created outside PiChamber (Pi CLI or any other writer of the cwd-scoped JSONL directory) are the same records: `sessions.list`/`open` hydrate from `getEntries()`, then overlay the live `session.messages` / `state.streamingMessage` turn while `isStreaming` is true, including an unpersisted user prompt that has not flushed to JSONL yet. A later prompt must overlay live turns onto that log rather than replace it. An unmatched `toolCall` projects as `running` only while `isStreaming` is authoritative; the same unmatched call in an idle persisted turn projects as a timestamped interruption error instead of malformed `completed` state. `getSession` also reports `isStreaming` and `lifecycle` so a restarted UI can restore the working/tooling state without waiting for the next token.
11
11
  - `createSessionDaemon(options)` starts/stops the authenticated local socket daemon and validates its endpoint and setup inputs.
12
12
  - `daemon-process.js` is the detached private-process entrypoint. It reads the daemon credential from its owner-only sidecar, starts the socket, writes non-secret identity state only after readiness, and removes only its own state on signal shutdown.
13
13
  - `ipc-client.js` is the server-only authenticated request client. It owns JSONL framing and never exposes endpoint or credential values to callers.
14
14
  - `runtime-registry.js` owns active runtime identities and session-local subscriptions. It rebinds the subscription after an SDK session replacement, rejects identity conflicts rather than displacing another runtime, and attempts every tracked disposal even when another disposal fails.
15
- - `session-jsonl.js` validates the cwd-scoped Pi JSONL directory before session discovery. Pi's SDK deliberately skips malformed discovery entries; this boundary maps malformed, missing, and unreadable candidates to stable explicit failures instead of an empty result. Directory names match Pi's SessionManager encoding, including Windows drive letters and Git Bash/MSYS/Cygwin/WSL `/c/...` paths so PiChamber and the Pi CLI share the same JSONL sessions.
16
- - `supervisor.js` resolves server-only overrides, creates the credential, serializes start/reuse/stop through an owner-only lock, validates daemon identity through `runtime.health`, forwards server-owned commands only after that identity check, and starts or stops the detached process. When the parent is Electron, the child is spawned with `ELECTRON_RUN_AS_NODE=1` so packaged Windows/macOS/Linux apps run the daemon as Node instead of a hidden second Electron GUI process.
15
+ - `session-jsonl.js` validates cwd-scoped Pi JSONL before discovery. List, create, and daemon startup require a valid session header per file; they do not read whole transcripts. `sessions.list` uses `listPiSessionJsonlDirectory` (header, first user prompt, latest rename from the file tail). Full-file validation remains on open of a specific session so a malformed body still fails that chat. Pi's SDK list is not the production list path because it scans every line of every log. Directory names match Pi's SessionManager encoding, including Windows drive letters and Git Bash/MSYS/Cygwin/WSL `/c/...` paths so PiChamber and the Pi CLI share the same JSONL sessions.
16
+ - `supervisor.js` resolves server-only overrides, creates the credential, serializes start/reuse/stop through an owner-only lock, validates daemon identity through `runtime.health`, forwards server-owned commands only after that identity check, and starts or stops the detached process. Reuse requires the ready-state sidecar `entrypoint` to match this checkout's `daemon-process.js`; a healthy daemon from another install (or an older daemon that never recorded an entrypoint) is stopped and replaced so local `bun run dev` cannot keep answering `getSession` without the live-turn overlay. When the parent is Electron, the child is spawned with `ELECTRON_RUN_AS_NODE=1` so packaged Windows/macOS/Linux apps run the daemon as Node instead of a hidden second Electron GUI process.
17
17
  - `../routes.js` registers authenticated public adapters before the generic proxy. It exposes only whitelisted runtime, project, session, provider-auth/configuration, settings, resource, and SSE event shapes; no browser response contains daemon paths, credentials, PIDs, headers, or socket framing. Session actions are path-selected, so an identity supplied in a request body cannot redirect a mutation. `archive-store.js` persists PiChamber-only opaque session archive metadata atomically beneath the PiChamber data root and never edits Pi JSONL. `settings-store.js` keeps PiChamber new-session defaults separate from Pi settings.json. `GET /api/pi/settings` copies leftover UI/desktop `defaultModel` / small / walkthrough strings into that sidecar when those sidecar fields are unset, then Sessions settings is the only editor. Sidecar `defaultThinkingByModel` is the per-model thinking map (`provider/model` keys). A stored global `defaultThinking` with a default model is copied onto that map key and dropped; global thinking without a default model remains a clamp-only fallback. `ui-settings-store.js` atomically owns shared PiChamber UI preferences at the data-root `settings.json`; it is exposed as `/api/pi/ui-settings`, not through removed `/api/config/*` aliases. `session-folders-store.js` validates and atomically persists the restored sidebar's folder snapshot at `/api/pi/session-folders`; a missing snapshot is distinct from an empty one. `custom-themes.js` serves validated custom-theme candidates from `/api/pi/themes` with per-file partial-failure isolation. `model-config-store.js` atomically updates Pi's credential-blind `models.json`; literal keys continue through Pi auth and headers are write-only. `providers.config.get` returns `config: null` for a provider that is not in `models.json` (built-in catalog entries) and fails only when that file is present but malformed.
18
18
  - `*.test.js` uses temporary cwd, agent, data, and runtime directories; it never loads a developer's Pi home, credentials, or sessions. On Windows the private endpoint is a named pipe, not a `.sock` file, and POSIX mode-bit assertions are skipped.
19
19
 
@@ -21,17 +21,18 @@ This module is the Pi-owned session-daemon foundation. A detached local daemon p
21
21
 
22
22
  - The endpoint is a Unix domain socket on POSIX or a Windows named pipe. TCP endpoints are rejected. Listeners and clients connect with IPC `path` options so Windows `\\.\pipe\pichamber-pi-session-daemon-<owner-key>` works the same as a POSIX socket path.
23
23
  - POSIX socket parent directories are mode `0700`; the socket is created mode `0600`. An existing endpoint fails startup rather than being unlinked without daemon-identity verification. Windows named-pipe ACLs are not yet equivalent owner-only OS permissions; credential authentication remains mandatory.
24
- - The daemon credential and non-secret state sidecars are mode `0600`; their PiChamber parent directory and the operation lock serialize start/reuse/stop. Exclusive lock create writes the owner pid in the same step. A leftover empty or malformed lock, a dead owner pid, or Windows `EPERM`/`EACCES` on exclusive create is retried and stolen after a short freshness window instead of failing as `DAEMON_LOCK_UNAVAILABLE`. Sidecar `chmod` failures on Windows are ignored after the file exists so readiness is still recorded. Health identity includes the daemon PID and must match the state sidecar before the supervisor reuses or signals a process.
24
+ - The daemon credential and non-secret state sidecars are mode `0600`; their PiChamber parent directory and the operation lock serialize start/reuse/stop. Exclusive lock create writes the owner pid in the same step. A leftover empty or malformed lock, a dead owner pid, or Windows `EPERM`/`EACCES` on exclusive create is retried and stolen after a short freshness window instead of failing as `DAEMON_LOCK_UNAVAILABLE`. Sidecar `chmod` failures on Windows are ignored after the file exists so readiness is still recorded. Health identity includes the daemon PID and must match the state sidecar before the supervisor reuses or signals a process. Reuse also requires the ready-state `entrypoint` to equal this host's `daemon-process.js`; a missing or different path is not identity-compatible and is replaced.
25
25
  - The public health route deliberately whitelists its response fields. It never returns a daemon endpoint, credential, PID, cwd, agent directory, or transcript data. Public transcript and event projections preserve daemon-redacted assistant error messages so the UI can distinguish provider/runtime failures instead of reducing every failure to `ASSISTANT_ERROR`.
26
26
  - A client must send an `authenticate` frame carrying the host-private daemon credential before it can issue a request. Credentials are never logged or returned.
27
27
  - Frames are LF-delimited JSON. They have a 1 MiB maximum buffered size. A malformed, oversized, unauthenticated, or invalid request closes the connection.
28
- - The private core commands are `runtime.health`, `projects.list/select`, `providers.list/config.get/models.set/status/login/login.respond/login.status/logout`, `settings.get/set`, `resources.list/update/prompts.create/prompts.delete`, and session list/create/open/rename/delete/tree/navigate/fork/clone/prompt/steer/followUp/abort/model/thinking/compact operations. Resource lists use opaque IDs and never disclose server paths. Only top-level Pi prompt templates and applicable AGENTS.md sources are editable; package/path resources and skills are browse-only. Resource writes are rejected while a session streams, use an atomic replace, then recreate the idle runtime. Project trust changes are persisted in Pi trust.json and likewise recreate an idle runtime; extensions remain disabled regardless of trust. Provider login submits API keys only across authenticated private IPC or brokers Pi's OAuth/device/manual-code interaction; public projections contain only opaque login IDs, prompts, URLs/device codes, model catalog/configuration, and stable states, never credentials or write-only headers. `providers.models.set` is rejected while a session streams, atomically persists Pi's `models.json`, then rehydrates the idle runtime so the catalog is authoritative immediately. `providers.list` projects only catalog and authentication-status metadata, including Pi-supported `thinkingLevels` (`null` map entries hidden, `xhigh`/`max` opt-in). Session thinking overrides are clamped to the live model's supported levels. `sessions.list` revalidates the selected cwd's JSONL directory before SDK discovery and returns no JSONL paths. The browser explicitly selects an existing user-chosen project directory before listing it; the daemon's startup cwd is an internal fallback, not a user-visible default project. Unnamed persisted sessions use their first prompt as a readable list title when available. `sessions.open` and `getSession` project the last assistant `provider`/`model`/`thinkingLevel` onto the session record so reopening a chat keeps that cache identity. Session selection validates a persisted JSONL path before Pi opens it; inactive mutations are never directed by a browser-supplied path. Attachment paths originate in the server-local upload map and cross only private IPC into Pi's tool context. Pi may persist or echo those paths in its authoritative JSONL transcript, so every public projection redacts them recursively: session titles/previews, user and assistant text/thinking, tool arguments, errors, snapshots, and live message events never expose the server-local path. The daemon maps Pi message lifecycle, deltas, tools, queue changes, model/thinking, compaction, interruption state, and session title updates (`session.updated` on create-with-title, first-prompt auto-title, and rename) to canonical events. Tool events carry redacted input arguments, cumulative partial-output snapshots, final output, error text, renderer metadata (edit diffs, truncation notes), and start/end timestamps. Persisted `toolResult` entries merge into their assistant `toolCall` parts as output/error/end time so hydrated sessions render complete tool cards; tool-result entries never surface as standalone messages. Assistant message projections and start events carry the owning user-message id so the restored turn renderer can group and display replies; all assistant entries in a tool loop remain attached to the latest user turn. Assistant message projections also pass through Pi's per-turn `Usage` (input, output, cacheRead, cacheWrite, totalTokens, and the same five numeric cost fields). The daemon sanitizes the raw payload through `projectUsage` before publication: every numeric must be finite and non-negative, the whole object is omitted when any field is missing or wrong type, and unknown keys are never copied. The `assistant.message.end` event carries the same sanitized usage so the last turn updates without waiting for a full `getSession` refetch. The public route whitelist re-applies the same sanitizer on each projection so a stale or malformed payload from a different daemon version cannot surface as `NaN` or `Infinity` in the reducer.
28
+ - The private core commands are `runtime.health`, `projects.list/select`, `providers.list/config.get/models.set/status/login/login.respond/login.status/logout`, `settings.get/set`, `resources.list/update/prompts.create/prompts.delete`, and session list/create/open/rename/delete/tree/navigate/fork/clone/prompt/steer/followUp/abort/model/thinking/compact operations. Resource lists use opaque IDs and never disclose server paths. Only top-level Pi prompt templates and applicable AGENTS.md sources are editable; package/path resources and skills are browse-only. Resource writes are rejected while a session streams, use an atomic replace, then recreate the idle runtime. Project trust changes are persisted in Pi trust.json and likewise recreate an idle runtime; extensions remain disabled regardless of trust. Provider login submits API keys only across authenticated private IPC or brokers Pi's OAuth/device/manual-code interaction; public projections contain only opaque login IDs, prompts, URLs/device codes, model catalog/configuration, and stable states, never credentials or write-only headers. `providers.models.set` is rejected while a session streams, atomically persists Pi's `models.json`, then rehydrates the idle runtime so the catalog is authoritative immediately. `providers.list` projects only catalog and authentication-status metadata, including Pi-supported `thinkingLevels` (`null` map entries hidden, `xhigh`/`max` opt-in). Session thinking overrides are clamped to the live model's supported levels. `sessions.list` checks each cwd JSONL header (not the full transcript) so a project with large session logs cannot spend tens of seconds on startup. SDK `SessionManager.list` is not used for that path; `listPiSessionJsonlDirectory` reads a header, a bounded prefix for the first user prompt, and a tail for the latest rename. Concurrent lists of the same directory share one in-flight result. Concurrent `sessions.open` of the same id share one in-flight runtime create; a duplicate that finishes later disposes itself and returns the registered runtime instead of `SESSION_RUNTIME_CONFLICT`. Full-file JSONL validation stays on open of a chosen session. The browser explicitly selects an existing user-chosen project directory before listing it; the daemon's startup cwd is an internal fallback, not a user-visible default project. Unnamed persisted sessions use their first prompt as a readable list title when available. `sessions.open` and `getSession` project the last assistant `provider`/`model`/`thinkingLevel` onto the session record so reopening a chat keeps that cache identity. Session selection validates a persisted JSONL path before Pi opens it; inactive mutations are never directed by a browser-supplied path. `sessions.open` resolves an id by Pi's `timestamp_<id>.jsonl` filename first so a missing deep link 404s without listing or fully reading every transcript in every cwd; SDK list and header scan remain the fallback for non-standard names. Directory-wide JSONL checks on list/create/startup are header-only; full-file validation stays on open of a chosen transcript. Attachment paths originate in the server-local upload map and cross only private IPC into Pi's tool context. Pi may persist or echo those paths in its authoritative JSONL transcript, so every public projection redacts them recursively: session titles/previews, user and assistant text/thinking, tool arguments, errors, snapshots, and live message events never expose the server-local path. The daemon maps Pi message lifecycle, deltas, tools, queue changes, model/thinking, compaction, interruption state, and session title updates (`session.updated` on create-with-title, first-prompt auto-title, and rename) to canonical events. Tool events carry redacted input arguments, cumulative partial-output snapshots, final output, error text, renderer metadata (edit diffs, truncation notes), and start/end timestamps. Persisted `toolResult` entries merge into their assistant `toolCall` parts as output/error/end time so hydrated sessions render complete tool cards; tool-result entries never surface as standalone messages. Assistant message projections and start events carry the owning user-message id so the restored turn renderer can group and display replies; all assistant entries in a tool loop remain attached to the latest user turn. Assistant message projections also pass through Pi's per-turn `Usage` (input, output, cacheRead, cacheWrite, totalTokens, and the same five numeric cost fields). The daemon sanitizes the raw payload through `projectUsage` before publication: every numeric must be finite and non-negative, the whole object is omitted when any field is missing or wrong type, and unknown keys are never copied. The `assistant.message.end` event carries the same sanitized usage so the last turn updates without waiting for a full `getSession` refetch. The public route whitelist re-applies the same sanitizer on each projection so a stale or malformed payload from a different daemon version cannot surface as `NaN` or `Infinity` in the reducer.
29
29
  - Each event and reconnect snapshot has a monotonic sequence. A reconnect receives a bounded contiguous replay after its cursor when available, otherwise an authoritative `session.snapshot`; unavailable runtimes are not represented as empty sessions.
30
30
  - Prompt, steer, and follow-up requests acknowledge acceptance immediately after Pi accepts the call; the daemon owns the still-running send promise and publishes any asynchronous rejection as `session.error`. A later prompt on the same session invalidates that rejection so it cannot error the new turn. If the rejected send left Pi streaming, the daemon aborts it. Steer/follow-up against an idle session starts a new prompt instead of `SESSION_NOT_RUNNING`. Agent completion is never held behind the 30-second public/private request budgets.
31
31
  - Registry keys combine the SDK session ID with its cwd. A replacement subscription is rebound to the replacement session so old-session events cannot be published under the new identity. A collision is a visible error; it never replaces or disposes an unrelated runtime.
32
32
  - After `agent_settled`, the daemon disposes an idle runtime after five minutes (injectable for tests). Disposal never deletes Pi JSONL; the daemon remembers the selected session file and recreates that runtime on the next private prompt. A new agent start cancels a pending disposal.
33
33
  - A startup validation failure writes only a stable failure code to the owner-only non-secret state sidecar. It never records a transcript, session path, or credential. The supervisor surfaces that code as unavailable instead of claiming an empty or idle session.
34
34
  - After a forced crash, the next health, request, or subscription probe re-runs supervised startup. Daemon readiness has its own 15-second bounded budget, separate from the shorter lock and shutdown budget, because loading Pi settings and provider/model catalogs can legitimately exceed five seconds. The supervisor removes an existing POSIX socket only when its recorded daemon PID is dead, the endpoint is a socket, and authenticated IPC deterministically refuses or times out. An endpoint that responds, authenticates incorrectly, returns malformed protocol data, or cannot be classified remains `DAEMON_ENDPOINT_UNVERIFIED`.
35
+ - The web server starts warming the supervisor immediately after HTTP listen without awaiting Pi provider/model initialization. A first request arriving during that warmup shares the supervisor's `startPromise`.
35
36
  - The daemon owns the runtime after clients disconnect. Disconnecting the web-server client cannot stop active Pi work.
36
37
 
37
38
  ## Deliberate limits
@@ -1,8 +1,10 @@
1
1
  import { readFile, chmod, rename, rm, writeFile } from 'node:fs/promises';
2
- import { dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
3
 
4
4
  import { createSessionDaemon } from './session-daemon.js';
5
5
 
6
+ const DAEMON_ENTRYPOINT = fileURLToPath(import.meta.url);
7
+
6
8
  const argument = (name) => {
7
9
  const index = process.argv.indexOf(name);
8
10
  return index === -1 ? undefined : process.argv[index + 1];
@@ -41,6 +43,7 @@ const writeReadyState = () => writeState({
41
43
  protocolVersion: 1,
42
44
  pid: process.pid,
43
45
  endpoint,
46
+ entrypoint: DAEMON_ENTRYPOINT,
44
47
  startedAt: new Date().toISOString(),
45
48
  });
46
49
 
@@ -19,7 +19,13 @@ import {
19
19
  import { createPiModelConfigStore } from '../model-config-store.js';
20
20
  import { clampThinkingLevel, getSupportedThinkingLevels, isPiThinkingLevel } from '../thinking-levels.js';
21
21
  import { createSessionRuntimeRegistry } from './runtime-registry.js';
22
- import { getPiSessionDirectory, validatePiSessionJsonlDirectory, validatePiSessionJsonlFile } from './session-jsonl.js';
22
+ import {
23
+ findPiSessionJsonlById,
24
+ getPiSessionDirectory,
25
+ listPiSessionJsonlDirectory,
26
+ validatePiSessionJsonlDirectory,
27
+ validatePiSessionJsonlFile,
28
+ } from './session-jsonl.js';
23
29
 
24
30
  const PROTOCOL_VERSION = 1;
25
31
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
@@ -80,10 +86,10 @@ export function createSessionDaemon({
80
86
  createRuntime = createPiSessionRuntime,
81
87
  healthMetadata = {},
82
88
  idleTimeoutMs = 5 * 60 * 1_000,
83
- listSessions = ({ cwd: sessionCwd, agentDir: sessionAgentDir = agentDir }) => SessionManager.list(
84
- sessionCwd,
85
- getPiSessionDirectory({ cwd: sessionCwd, agentDir: sessionAgentDir }),
86
- ),
89
+ listSessions = ({ cwd: sessionCwd, agentDir: sessionAgentDir = agentDir }) => listPiSessionJsonlDirectory({
90
+ cwd: sessionCwd,
91
+ agentDir: sessionAgentDir,
92
+ }),
87
93
  createSettingsManager = ({ cwd: settingsCwd, agentDir: settingsAgentDir = agentDir, projectTrusted }) => SettingsManager.create(
88
94
  settingsCwd,
89
95
  settingsAgentDir,
@@ -335,9 +341,8 @@ export function createSessionDaemon({
335
341
  idleDisposeTimers.set(sessionId, timer);
336
342
  };
337
343
 
338
- const listSessionItems = async (requestedDirectory) => {
339
- const targetDir = requestedDirectory ? await resolveDirectory(requestedDirectory) : (activeDirectory || cwd);
340
- await validatePiSessionJsonlDirectory({ cwd: targetDir, agentDir });
344
+ const listInflightByDirectory = new Map();
345
+ const listSessionItemsUnshared = async (targetDir) => {
341
346
  const sessions = await listSessions({ cwd: targetDir, agentDir });
342
347
  if (!Array.isArray(sessions)) {
343
348
  throw new SessionDaemonProtocolError('INVALID_SESSION', 'Pi returned an invalid session collection.');
@@ -392,6 +397,17 @@ export function createSessionDaemon({
392
397
  });
393
398
  };
394
399
 
400
+ const listSessionItems = async (requestedDirectory) => {
401
+ const targetDir = requestedDirectory ? await resolveDirectory(requestedDirectory) : (activeDirectory || cwd);
402
+ const inflight = listInflightByDirectory.get(targetDir);
403
+ if (inflight) return inflight;
404
+ const pending = listSessionItemsUnshared(targetDir).finally(() => {
405
+ if (listInflightByDirectory.get(targetDir) === pending) listInflightByDirectory.delete(targetDir);
406
+ });
407
+ listInflightByDirectory.set(targetDir, pending);
408
+ return pending;
409
+ };
410
+
395
411
  const renameSession = async (payload) => {
396
412
  if (!payload || typeof payload !== 'object' || typeof payload.sessionId !== 'string' || payload.sessionId.length === 0
397
413
  || typeof payload.title !== 'string' || payload.title.trim().length === 0 || payload.title.length > 256) {
@@ -428,6 +444,21 @@ export function createSessionDaemon({
428
444
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
429
445
  throw new SessionDaemonProtocolError('INVALID_SESSION', 'The Pi session does not exist.');
430
446
  }
447
+ // Filename identity is enough to 404 a missing id without listing or
448
+ // fully reading every transcript in every cwd. Deep-linking a stale
449
+ // `?session=` used to hang here while `validatePiSessionJsonlDirectory`
450
+ // scanned multi-megabyte JSONL files that cannot be the target.
451
+ try {
452
+ const named = await findPiSessionJsonlById({ sessionId, agentDir });
453
+ if (named?.path && named.cwd) {
454
+ await validatePiSessionJsonlFile(named.path);
455
+ const directory = await resolveDirectory(named.cwd);
456
+ knownDirectories.add(directory);
457
+ return { target: { id: sessionId, path: named.path }, directory };
458
+ }
459
+ } catch {
460
+ // Fall through to SDK list / header scan for non-standard filenames.
461
+ }
431
462
  const candidateDirs = new Set();
432
463
  if (requestedDirectory) {
433
464
  try {
@@ -440,7 +471,6 @@ export function createSessionDaemon({
440
471
 
441
472
  for (const dir of candidateDirs) {
442
473
  try {
443
- await validatePiSessionJsonlDirectory({ cwd: dir, agentDir });
444
474
  const sessions = await listSessions({ cwd: dir, agentDir });
445
475
  const target = Array.isArray(sessions) ? sessions.find((session) => session?.id === sessionId) : undefined;
446
476
  if (target && typeof target.path === 'string' && target.path.length > 0) {
@@ -492,7 +522,8 @@ export function createSessionDaemon({
492
522
  throw new SessionDaemonProtocolError('INVALID_SESSION', 'The Pi session does not exist.');
493
523
  };
494
524
 
495
- const activateSession = async (sessionId, requestedDirectory) => {
525
+ const activateInflightBySessionId = new Map();
526
+ const activateSessionUnshared = async (sessionId, requestedDirectory) => {
496
527
  if (!runtimeRegistry) {
497
528
  runtimeRegistry = createSessionRuntimeRegistry({
498
529
  onSessionEvent: ({ cwd: eventCwd, sessionId: eventSessionId }, event) => publishSessionEvent(eventSessionId, event, eventCwd),
@@ -521,16 +552,89 @@ export function createSessionDaemon({
521
552
  if (newRuntime.session?.sessionId !== sessionId && typeof newRuntime.switchSession === 'function') {
522
553
  await newRuntime.switchSession(target.path);
523
554
  }
524
- runtimeRegistry.register(newRuntime, { cwd: directory });
555
+ const raced = runtimeRegistry.findBySessionId(sessionId);
556
+ if (raced) {
557
+ try { await newRuntime.dispose?.(); } catch { /* keep the winner */ }
558
+ runtime = raced;
559
+ if (raced.cwd) activeDirectory = raced.cwd;
560
+ return raced;
561
+ }
562
+ try {
563
+ runtimeRegistry.register(newRuntime, { cwd: directory });
564
+ } catch (error) {
565
+ if (error?.code === 'SESSION_RUNTIME_CONFLICT') {
566
+ try { await newRuntime.dispose?.(); } catch { /* keep the winner */ }
567
+ const winner = runtimeRegistry.findBySessionId(sessionId)
568
+ || runtimeRegistry.get({ cwd: directory, sessionId });
569
+ if (winner) {
570
+ runtime = winner;
571
+ if (winner.cwd) activeDirectory = winner.cwd;
572
+ return winner;
573
+ }
574
+ }
575
+ throw error;
576
+ }
525
577
  runtime = newRuntime;
526
578
  activeDirectory = directory;
527
579
  rememberRuntimeSession();
528
580
  return newRuntime;
529
581
  };
530
582
 
583
+ const activateSession = async (sessionId, requestedDirectory) => {
584
+ const inflight = activateInflightBySessionId.get(sessionId);
585
+ if (inflight) return inflight;
586
+ const pending = activateSessionUnshared(sessionId, requestedDirectory).finally(() => {
587
+ if (activateInflightBySessionId.get(sessionId) === pending) activateInflightBySessionId.delete(sessionId);
588
+ });
589
+ activateInflightBySessionId.set(sessionId, pending);
590
+ return pending;
591
+ };
592
+
593
+ const liveProjectionEntries = (session, persisted) => {
594
+ if (!session?.isStreaming) return persisted;
595
+ const liveMessages = [];
596
+ if (Array.isArray(session.messages)) liveMessages.push(...session.messages);
597
+ const streamingMessage = session.state?.streamingMessage;
598
+ if (streamingMessage && liveMessages[liveMessages.length - 1] !== streamingMessage) {
599
+ liveMessages.push(streamingMessage);
600
+ }
601
+ if (liveMessages.length === 0) return persisted;
602
+
603
+ const persistedKeys = new Set();
604
+ for (const entry of persisted) {
605
+ if (entry?.type !== 'message' || !entry.message) continue;
606
+ const timestamp = typeof entry.message.timestamp === 'number' ? entry.message.timestamp : Date.parse(entry.timestamp);
607
+ persistedKeys.add(`${entry.message.role}:${Number.isFinite(timestamp) ? timestamp : ''}`);
608
+ }
609
+
610
+ const entries = [...persisted];
611
+ let liveIndex = 0;
612
+ const liveAssistantId = streamingMessageIds.get(session.sessionId);
613
+ for (const message of liveMessages) {
614
+ if (!message || (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'toolResult')) continue;
615
+ const timestamp = typeof message.timestamp === 'number' ? message.timestamp : undefined;
616
+ const key = `${message.role}:${timestamp ?? ''}`;
617
+ if (timestamp !== undefined && persistedKeys.has(key)) continue;
618
+ persistedKeys.add(key);
619
+ const id = message.role === 'assistant' && liveAssistantId
620
+ ? liveAssistantId
621
+ : `live-${session.sessionId}-${liveIndex}`;
622
+ liveIndex += 1;
623
+ entries.push({
624
+ type: 'message',
625
+ id,
626
+ timestamp: new Date(timestamp || Date.now()).toISOString(),
627
+ message,
628
+ });
629
+ }
630
+ return entries;
631
+ };
632
+
531
633
  const projectMessageEntries = (session, targetDir = activeDirectory || cwd) => {
532
- const entries = session?.sessionManager?.getEntries?.();
533
- if (!Array.isArray(entries)) return [];
634
+ const persisted = session?.sessionManager?.getEntries?.();
635
+ const entries = liveProjectionEntries(session, Array.isArray(persisted) ? persisted : []);
636
+ if (entries.length === 0) return [];
637
+ const streaming = session?.isStreaming === true;
534
638
  const toolResults = new Map();
535
639
  for (const entry of entries) {
536
640
  if (entry?.type !== 'message' || !entry.message || entry.message.role !== 'toolResult' || typeof entry.message.toolCallId !== 'string') continue;
@@ -563,6 +667,8 @@ export function createSessionDaemon({
563
667
  if (part?.type === 'thinking') return [{ type: 'thinking', id: `${entry.id}:thinking:${index}`, index, text: redactAttachmentPaths(part.thinking) }];
564
668
  if (part?.type === 'toolCall') {
565
669
  const result = toolResults.get(part.id);
670
+ const running = streaming && !result;
671
+ const interrupted = !running && !result;
566
672
  return [{
567
673
  type: 'tool',
568
674
  id: `${entry.id}:tool:${part.id}`,
@@ -570,12 +676,20 @@ export function createSessionDaemon({
570
676
  toolCallId: part.id,
571
677
  name: part.name,
572
678
  input: redactAttachmentValues(part.arguments),
573
- state: result?.isError ? 'error' : 'completed',
679
+ state: result?.isError || interrupted ? 'error' : running ? 'running' : 'completed',
574
680
  ...(result?.output ? { output: result.output } : {}),
575
- ...(result?.error ? { error: result.error } : {}),
576
- ...(result?.isError ? { isError: true } : {}),
681
+ ...(result?.error
682
+ ? { error: result.error }
683
+ : interrupted
684
+ ? { error: 'Tool was interrupted before completion.' }
685
+ : {}),
686
+ ...(result?.isError || interrupted ? { isError: true } : {}),
577
687
  ...(result?.metadata ? { metadata: result.metadata } : {}),
578
- ...(Number.isFinite(result?.endedAt) ? { endedAt: result.endedAt } : {}),
688
+ ...(Number.isFinite(result?.endedAt)
689
+ ? { endedAt: result.endedAt }
690
+ : interrupted
691
+ ? { endedAt: createdAt }
692
+ : {}),
579
693
  }];
580
694
  }
581
695
  return [];
@@ -608,6 +722,7 @@ export function createSessionDaemon({
608
722
  const sessionModel = lastAssistant?.model
609
723
  ?? (model?.provider && model?.id ? { providerId: model.provider, modelId: model.id } : undefined);
610
724
  const sessionThinking = lastAssistant?.thinkingLevel || session.thinkingLevel;
725
+ const isStreaming = session.isStreaming === true;
611
726
  return {
612
727
  session: {
613
728
  id: session.sessionId, directory: targetDir, createdAt, updatedAt: createdAt,
@@ -618,6 +733,8 @@ export function createSessionDaemon({
618
733
  },
619
734
  messages,
620
735
  lastSequence: sequence,
736
+ isStreaming,
737
+ lifecycle: isStreaming ? 'busy' : 'idle',
621
738
  };
622
739
  };
623
740
 
@@ -1378,17 +1495,18 @@ export function createSessionDaemon({
1378
1495
  const deleteSession = async (sessionId, requestedDirectory) => {
1379
1496
  const active = runtimeRegistry?.findBySessionId(sessionId);
1380
1497
  let targetDir = requestedDirectory ? await resolveDirectory(requestedDirectory) : active?.cwd || activeDirectory || cwd;
1498
+ const activeSessionFile = active?.session?.sessionManager?.getSessionFile?.();
1381
1499
  if (active) {
1382
1500
  if (active.session?.isStreaming) await active.session.abort();
1383
1501
  await runtimeRegistry?.dispose(active);
1384
1502
  if (runtime === active) runtime = undefined;
1385
1503
  }
1386
- try {
1504
+ if (active && typeof activeSessionFile === 'string' && activeSessionFile.length > 0) {
1505
+ await rm(activeSessionFile, { force: true });
1506
+ } else if (!active) {
1387
1507
  const { target, directory } = await findPersistedSession(sessionId, targetDir);
1388
1508
  targetDir = directory;
1389
1509
  await rm(target.path, { force: false });
1390
- } catch (error) {
1391
- if (!active || error?.code !== 'INVALID_SESSION') throw error;
1392
1510
  }
1393
1511
  publish('session.lifecycle', { state: 'idle', deleted: true }, sessionId, targetDir);
1394
1512
  };
@@ -390,6 +390,93 @@ describe('Pi session daemon spike', () => {
390
390
  await client.close();
391
391
  });
392
392
 
393
+ it('shares an in-flight sessions.list for the same directory', async () => {
394
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-share-'));
395
+ const endpoint = testDaemonEndpoint(root);
396
+ const agentDir = join(root, 'agent');
397
+ await mkdir(agentDir, { recursive: true });
398
+ let calls = 0;
399
+ daemon = createSessionDaemon({
400
+ endpoint,
401
+ credential,
402
+ cwd: root,
403
+ agentDir,
404
+ createRuntime: async () => ({ session: new FakeSession(), async dispose() {} }),
405
+ listSessions: async () => {
406
+ calls += 1;
407
+ await new Promise((resolve) => setTimeout(resolve, 80));
408
+ return [{
409
+ path: join(root, 'session.jsonl'),
410
+ id: 'pi-session-1',
411
+ cwd: root,
412
+ created: new Date('2026-01-01T00:00:00.000Z'),
413
+ modified: new Date('2026-01-02T00:00:00.000Z'),
414
+ }];
415
+ },
416
+ });
417
+ await daemon.start();
418
+
419
+ const first = connectClient(endpoint);
420
+ await first.authenticate();
421
+ const second = connectClient(endpoint);
422
+ await second.authenticate();
423
+ const [left, right] = await Promise.all([
424
+ first.request('sessions.list', { directory: root }),
425
+ second.request('sessions.list', { directory: root }),
426
+ ]);
427
+ expect(calls).toBe(1);
428
+ expect(left.result.sessions[0].session.id).toBe('pi-session-1');
429
+ expect(right.result.sessions[0].session.id).toBe('pi-session-1');
430
+ await first.close();
431
+ await second.close();
432
+ });
433
+
434
+ it('shares an in-flight sessions.open for the same session id', async () => {
435
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-open-share-'));
436
+ const endpoint = testDaemonEndpoint(root);
437
+ const agentDir = join(root, 'agent');
438
+ await mkdir(agentDir, { recursive: true });
439
+ const sessionFile = join(root, 'session.jsonl');
440
+ await writeFile(sessionFile, `{"type":"session","id":"pi-session-1","cwd":${JSON.stringify(root)}}\n`);
441
+ let calls = 0;
442
+ daemon = createSessionDaemon({
443
+ endpoint,
444
+ credential,
445
+ cwd: root,
446
+ agentDir,
447
+ listSessions: async () => [{
448
+ path: sessionFile,
449
+ id: 'pi-session-1',
450
+ cwd: root,
451
+ created: new Date('2026-01-01T00:00:00.000Z'),
452
+ modified: new Date('2026-01-02T00:00:00.000Z'),
453
+ }],
454
+ createRuntime: async (options) => {
455
+ calls += 1;
456
+ await new Promise((resolve) => setTimeout(resolve, 80));
457
+ return new FakeRuntime({
458
+ cwd: options.cwd,
459
+ session: new FakeSession('pi-session-1', options.sessionFile),
460
+ });
461
+ },
462
+ });
463
+ await daemon.start();
464
+
465
+ const first = connectClient(endpoint);
466
+ await first.authenticate();
467
+ const second = connectClient(endpoint);
468
+ await second.authenticate();
469
+ const [left, right] = await Promise.all([
470
+ first.request('sessions.open', { sessionId: 'pi-session-1', directory: root }),
471
+ second.request('sessions.open', { sessionId: 'pi-session-1', directory: root }),
472
+ ]);
473
+ expect(calls).toBe(1);
474
+ expect(left.result.session.id).toBe('pi-session-1');
475
+ expect(right.result.session.id).toBe('pi-session-1');
476
+ await first.close();
477
+ await second.close();
478
+ });
479
+
393
480
  it('does not include in-memory sessions from another directory when listing a newly selected project directory', async () => {
394
481
  const rootA = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-a-'));
395
482
  const rootB = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-b-'));
@@ -1198,6 +1285,127 @@ describe('Pi session daemon spike', () => {
1198
1285
  await client.close();
1199
1286
  });
1200
1287
 
1288
+ it('projects unmatched tools as running only while the session is authoritative-busy', async () => {
1289
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-live-hydrate-'));
1290
+ const endpoint = testDaemonEndpoint(root);
1291
+ const projectDir = join(root, 'project');
1292
+ const agentDir = join(root, 'agent');
1293
+ await mkdir(projectDir, { recursive: true });
1294
+ await mkdir(agentDir, { recursive: true });
1295
+ const session = new FakeSession('pi-session-live');
1296
+ session.isStreaming = true;
1297
+ session.entries = [
1298
+ {
1299
+ type: 'message',
1300
+ id: 'user-1',
1301
+ timestamp: '2026-01-01T00:00:01.000Z',
1302
+ message: { role: 'user', content: 'run it', timestamp: 1_000 },
1303
+ },
1304
+ {
1305
+ type: 'message',
1306
+ id: 'assistant-1',
1307
+ timestamp: '2026-01-01T00:00:01.100Z',
1308
+ message: {
1309
+ role: 'assistant',
1310
+ content: [
1311
+ { type: 'text', text: 'calling bash' },
1312
+ { type: 'toolCall', id: 'tool-live', name: 'bash', arguments: { command: 'ls' } },
1313
+ ],
1314
+ provider: 'test',
1315
+ model: 'model',
1316
+ timestamp: 1_100,
1317
+ },
1318
+ },
1319
+ ];
1320
+ session.messages = [
1321
+ { role: 'user', content: 'run it', timestamp: 1_000 },
1322
+ {
1323
+ role: 'assistant',
1324
+ content: [
1325
+ { type: 'text', text: 'calling bash' },
1326
+ { type: 'toolCall', id: 'tool-live', name: 'bash', arguments: { command: 'ls' } },
1327
+ ],
1328
+ provider: 'test',
1329
+ model: 'model',
1330
+ timestamp: 1_100,
1331
+ },
1332
+ ];
1333
+
1334
+ daemon = createSessionDaemon({
1335
+ endpoint,
1336
+ credential,
1337
+ cwd: projectDir,
1338
+ agentDir,
1339
+ createRuntime: async () => ({ session, async dispose() {} }),
1340
+ listSessions: async () => [],
1341
+ });
1342
+ await daemon.start();
1343
+
1344
+ const client = connectClient(endpoint);
1345
+ await client.authenticate();
1346
+ const detail = await client.request('sessions.create', { cwd: projectDir });
1347
+ expect(detail.result.isStreaming).toBe(true);
1348
+ expect(detail.result.lifecycle).toBe('busy');
1349
+ expect(detail.result.messages).toHaveLength(2);
1350
+ expect(detail.result.messages[1].parts).toEqual(expect.arrayContaining([
1351
+ expect.objectContaining({ type: 'text', text: 'calling bash' }),
1352
+ expect.objectContaining({ type: 'tool', name: 'bash', state: 'running', toolCallId: 'tool-live' }),
1353
+ ]));
1354
+
1355
+ session.isStreaming = false;
1356
+ const settled = await client.request('sessions.open', { sessionId: session.sessionId, cwd: projectDir });
1357
+ expect(settled.result.lifecycle).toBe('idle');
1358
+ expect(settled.result.messages[1].parts).toEqual(expect.arrayContaining([
1359
+ expect.objectContaining({
1360
+ type: 'tool',
1361
+ name: 'bash',
1362
+ state: 'error',
1363
+ toolCallId: 'tool-live',
1364
+ isError: true,
1365
+ error: 'Tool was interrupted before completion.',
1366
+ endedAt: expect.any(Number),
1367
+ }),
1368
+ ]));
1369
+ await client.close();
1370
+ });
1371
+
1372
+ it('overlays an unpersisted live user prompt onto getSession while streaming', async () => {
1373
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-live-user-'));
1374
+ const endpoint = testDaemonEndpoint(root);
1375
+ const projectDir = join(root, 'project');
1376
+ const agentDir = join(root, 'agent');
1377
+ await mkdir(projectDir, { recursive: true });
1378
+ await mkdir(agentDir, { recursive: true });
1379
+ const session = new FakeSession('pi-session-live-user');
1380
+ session.isStreaming = true;
1381
+ session.entries = [];
1382
+ session.messages = [
1383
+ { role: 'user', content: 'just sent', timestamp: 2_000 },
1384
+ ];
1385
+
1386
+ daemon = createSessionDaemon({
1387
+ endpoint,
1388
+ credential,
1389
+ cwd: projectDir,
1390
+ agentDir,
1391
+ createRuntime: async () => ({ session, async dispose() {} }),
1392
+ listSessions: async () => [],
1393
+ });
1394
+ await daemon.start();
1395
+
1396
+ const client = connectClient(endpoint);
1397
+ await client.authenticate();
1398
+ const detail = await client.request('sessions.create', { cwd: projectDir });
1399
+ expect(detail.result.isStreaming).toBe(true);
1400
+ expect(detail.result.lifecycle).toBe('busy');
1401
+ expect(detail.result.messages).toEqual([
1402
+ expect.objectContaining({
1403
+ message: expect.objectContaining({ role: 'user', text: 'just sent' }),
1404
+ }),
1405
+ ]);
1406
+ await client.close();
1407
+ });
1408
+
1201
1409
  it('projects Pi usage in getSession messages and message_end events, omitting malformed payloads', async () => {
1202
1410
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-usage-'));
1203
1411
  const endpoint = testDaemonEndpoint(root);