@rubytech/create-realagent 1.0.663 → 1.0.665

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/dist/index.js +5 -1
  2. package/package.json +1 -1
  3. package/payload/platform/neo4j/schema.cypher +34 -1
  4. package/payload/platform/plugins/docs/references/memory-guide.md +2 -2
  5. package/payload/platform/plugins/docs/references/platform.md +1 -1
  6. package/payload/platform/plugins/docs/references/troubleshooting.md +24 -6
  7. package/payload/platform/scripts/vnc.sh +174 -2
  8. package/payload/server/public/assets/{admin-C9qoVb2l.js → admin-Brug36E-.js} +5 -5
  9. package/payload/server/public/assets/data-woLf2Tmp.js +1 -0
  10. package/payload/server/public/assets/{file-lmzx24EO.js → file-rN5uuyaV.js} +1 -1
  11. package/payload/server/public/assets/{graph-DkjvCb8B.js → graph-BYaOEZUg.js} +16 -16
  12. package/payload/server/public/assets/{house-ClhI06TA.js → house-DnFgpCt2.js} +1 -1
  13. package/payload/server/public/assets/jsx-runtime-CSCPZpLN.css +1 -0
  14. package/payload/server/public/assets/{public-Dz33-dIE.js → public-BRrqpeVH.js} +1 -1
  15. package/payload/server/public/assets/{share-2-MZ4MqbjS.js → share-2-DLjRUEiG.js} +1 -1
  16. package/payload/server/public/assets/{useVoiceRecorder-Cm0G51D_.js → useVoiceRecorder-D_efR3Nx.js} +1 -1
  17. package/payload/server/public/assets/{x-CLhtM_Mh.js → x-L6KPMfIN.js} +1 -1
  18. package/payload/server/public/data.html +6 -6
  19. package/payload/server/public/graph.html +6 -6
  20. package/payload/server/public/index.html +7 -7
  21. package/payload/server/public/public.html +4 -4
  22. package/payload/server/server.js +360 -126
  23. package/payload/platform/plugins/admin/mcp/dist/lib/onboarding.test.d.ts +0 -2
  24. package/payload/platform/plugins/admin/mcp/dist/lib/onboarding.test.d.ts.map +0 -1
  25. package/payload/platform/plugins/admin/mcp/dist/lib/onboarding.test.js +0 -224
  26. package/payload/platform/plugins/admin/mcp/dist/lib/onboarding.test.js.map +0 -1
  27. package/payload/platform/plugins/documents/mcp/dist/index.d.ts +0 -2
  28. package/payload/platform/plugins/documents/mcp/dist/index.d.ts.map +0 -1
  29. package/payload/platform/plugins/documents/mcp/dist/index.js +0 -98
  30. package/payload/platform/plugins/documents/mcp/dist/index.js.map +0 -1
  31. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.test.d.ts +0 -2
  32. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.test.d.ts.map +0 -1
  33. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.test.js +0 -233
  34. package/payload/platform/plugins/memory/mcp/dist/lib/semantic-chunker.test.js.map +0 -1
  35. package/payload/platform/plugins/memory/mcp/dist/scripts/graph-prune.d.ts +0 -18
  36. package/payload/platform/plugins/memory/mcp/dist/scripts/graph-prune.d.ts.map +0 -1
  37. package/payload/platform/plugins/memory/mcp/dist/scripts/graph-prune.js +0 -80
  38. package/payload/platform/plugins/memory/mcp/dist/scripts/graph-prune.js.map +0 -1
  39. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-run.d.ts +0 -7
  40. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-run.d.ts.map +0 -1
  41. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-run.js +0 -10
  42. package/payload/platform/plugins/memory/mcp/dist/tools/graph-prune-run.js.map +0 -1
  43. package/payload/platform/plugins/waitlist/mcp/dist/lib/anthropic.d.ts +0 -23
  44. package/payload/platform/plugins/waitlist/mcp/dist/lib/anthropic.d.ts.map +0 -1
  45. package/payload/platform/plugins/waitlist/mcp/dist/lib/anthropic.js +0 -115
  46. package/payload/platform/plugins/waitlist/mcp/dist/lib/anthropic.js.map +0 -1
  47. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.d.ts +0 -12
  48. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.d.ts.map +0 -1
  49. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.js +0 -197
  50. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.js.map +0 -1
  51. package/payload/server/public/assets/data-C-WE3FGr.js +0 -1
  52. package/payload/server/public/assets/jsx-runtime-CLCFnMYD.css +0 -1
  53. /package/payload/server/public/assets/{jsx-runtime-ImbU973I.js → jsx-runtime-XWiDQoTG.js} +0 -0
package/dist/index.js CHANGED
@@ -219,7 +219,11 @@ function installSystemDeps() {
219
219
  if (canSudo()) {
220
220
  shell("apt-get", ["update"], { sudo: true });
221
221
  shell("apt-get", ["install", "-y", "curl", "git", "unzip", "jq", "avahi-daemon", "avahi-utils", "poppler-utils", "ffmpeg"], { sudo: true });
222
- shell("apt-get", ["install", "-y", "tigervnc-standalone-server", "python3-websockify", "novnc", "xdg-utils", "chromium"], { sudo: true });
222
+ // xterm is the fallback terminal-emulator binary for the VNC-rendered
223
+ // Terminal surface (Task 627). Always installed to guarantee vnc.sh
224
+ // start-terminal has a binary to spawn on Bookworm Pis where
225
+ // gnome-terminal is not preinstalled (avoids pulling ~180MB of GNOME).
226
+ shell("apt-get", ["install", "-y", "tigervnc-standalone-server", "python3-websockify", "novnc", "xdg-utils", "chromium", "xterm"], { sudo: true });
223
227
  shell("apt-get", ["install", "-y", "hostapd", "dnsmasq"], { sudo: true });
224
228
  // tmux backs the admin terminal's persistent named session (Task 591).
225
229
  // ttyd is NOT in Debian Bookworm's apt repo (Task 602) — it ships as a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.663",
3
+ "version": "1.0.665",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -263,6 +263,13 @@ ON EACH [k.summary, k.content];
263
263
  // (Conversation)-[:BELONGS_TO]->(LocalBusiness).
264
264
  // Public conversations carry a visitorId (browser cookie) and
265
265
  // agentSlug for session resume across page reloads and server restarts.
266
+ // Task 621 — carries a `summary` string denormalised from the first
267
+ // user-role message (first 200 chars, set once, no overwrites) to
268
+ // provide a stable human-identifiable label on the /graph canvas.
269
+ // Conversations that never receive a user message (assistant-only
270
+ // seed, etc.) leave `summary` NULL; the /graph client's pickShortLabel
271
+ // falls back to `Conv <shortId>` so empty conversations stay
272
+ // distinguishable from each other.
266
273
  // ----------------------------------------------------------
267
274
 
268
275
  CREATE CONSTRAINT conversation_id_unique IF NOT EXISTS
@@ -297,7 +304,13 @@ OPTIONS {
297
304
 
298
305
  // ----------------------------------------------------------
299
306
  // Message node — individual messages within a Conversation
300
- // Linked via (Message)-[:PART_OF]->(Conversation).
307
+ // Linked via (Message)-[:PART_OF]->(Conversation) to the parent.
308
+ // Linked via (Message)-[:NEXT]->(Message) to the next message in
309
+ // insertion order (Task 621). The chain is linear — one outgoing
310
+ // NEXT per message — so a Conversation with N messages has N-1
311
+ // NEXT edges. Concurrent writes against the same Conversation
312
+ // CAN fork the chain under Neo4j's READ_COMMITTED default; tracked
313
+ // under Task 624.
301
314
  // Vector-indexed for semantic search over conversation history.
302
315
  // ----------------------------------------------------------
303
316
 
@@ -615,6 +628,26 @@ FOR (au:AdminUser) REQUIRE au.userId IS UNIQUE;
615
628
  CREATE INDEX graph_preference_account_user IF NOT EXISTS
616
629
  FOR (p:GraphPreference) ON (p.accountId, p.userId);
617
630
 
631
+ // ----------------------------------------------------------
632
+ // ReviewAlert — review-detector rule-match aggregation
633
+ //
634
+ // One alert per (accountId, ruleId); subsequent rule matches bump
635
+ // lastMatchAt and cumulativeMatchCount via ON MATCH. Written by
636
+ // platform/ui/app/lib/review-detector/writer.ts, read by the admin
637
+ // chat's alert-surfacing tools. Listed in FILTER_EXCLUDED_LABELS
638
+ // (graph-labels.ts, Task 626) so it never surfaces as a /graph filter
639
+ // row, but registered in GRAPH_LABEL_COLOURS so neighbourhood-mode
640
+ // drilldown / search hits can still render it.
641
+ //
642
+ // Composite MERGE key (accountId, ruleId) — no UNIQUE constraint for
643
+ // the same reason as GraphPreference: the composite MERGE is
644
+ // idempotent and a composite constraint would add schema surface
645
+ // for no behavioural gain.
646
+ // ----------------------------------------------------------
647
+
648
+ CREATE INDEX review_alert_account_rule IF NOT EXISTS
649
+ FOR (a:ReviewAlert) ON (a.accountId, a.ruleId);
650
+
618
651
  // ----------------------------------------------------------
619
652
  // ToolCall — durable audit trail for agent tool invocations
620
653
  //
@@ -84,9 +84,9 @@ Ask naturally:
84
84
 
85
85
  Maxy answers relational questions — "list all my people", "how many tasks do I have", "find the person with email X", "show me the 20 most recently created nodes" — via direct read-only Cypher against your Neo4j. This is faster and more precise than semantic search when the question is "the exact set where", not "things similar to".
86
86
 
87
- You can also open a visual view of your graph at any time from the burger menu → **Graph**. Click the **Filter** button in the toolbar to open the filter menu — it lists only the node types that actually exist in your graph (so you won't see chips for types you've never written). Active chips render a force-directed map, coloured by label (Person, Service, KnowledgeDocument, Task, …). Click a node to see its properties and explore its neighbourhood; click the **Back** control or empty canvas to return to your filter view with the same chips still selected. Type in the search box to highlight matches.
87
+ You can also open a visual view of your graph at any time from the burger menu → **Graph**. Click the **Filter** button in the toolbar to open the filter menu — it lists only the node types that actually exist in your graph, one row per type, showing your per-type node count and sorted so the most-connected types sit at the top. Leaf types that only appear inside a parent (messages inside a conversation, sections inside a document) are never filter rows — you reach them by clicking the parent and exploring its neighbourhood. Infrastructural types (`ToolCall`, `WorkflowRun`, `WorkflowStep`, `ReviewAlert`) never appear as filter rows — the first three are execution plumbing, and `ReviewAlert` has its own dedicated surface in admin chat. Active rows render a force-directed map, coloured by label (Person, Service, KnowledgeDocument, Task, …). Click a node to see its properties and explore its neighbourhood; click the **Back** control or empty canvas to return to your filter view with the same rows still selected. Type in the search box to highlight matches; submitting a search also widens the filter to include any node types the hits belong to, so relevant matches render instead of disappearing into a "not in current view" banner. The **×** buttons on the search box and inside the filter menu clear the current search and the current selection respectively — clearing the filter selection does not touch your saved default.
88
88
 
89
- **Save a default view:** once you have the chips you want, click **Set default view** in the filter menu. Next time you open **Graph**, those chips are pre-selected and your data renders immediately. The default is per-admin, per-account — each admin on each account has their own.
89
+ **Save a default view:** once you have the rows you want, click **Set default view** in the filter menu. Next time you open **Graph**, those rows are pre-selected and your data renders immediately. The default is per-admin, per-account — each admin on each account has their own.
90
90
 
91
91
  **Delete a node:** drag it to the trash icon top-right of the canvas. No confirmation — deletes are reversible for 30 days. To restore, toggle **Show trashed** inside the filter menu and click **Restore** on the node, or ask Maxy in chat ("restore the <label> I just deleted").
92
92
 
@@ -72,7 +72,7 @@ The Software Update window mounts the terminal lazily: neither the terminal, its
72
72
 
73
73
  Because the terminal is the only surface, it narrates its own state when something goes wrong. The moment you click Upgrade, the terminal echoes a timestamped `[upgrade] starting at <UTC> — shell+ws+tmux+xterm chain OK` line before the `npx` invocation begins — that single line confirms the WebSocket, tmux session, shell, and xterm renderer are all working end-to-end. If 5 seconds then pass with no output from `npx`, the terminal itself writes a `[terminal] no bytes from upstream in 5s — ws.readyState=…, bytesReceived=…, attempt=…` diagnostic into its own buffer, and repeats that narration every 30 seconds of continued silence. If even that narration never appears within ~10 seconds of click, the xterm renderer or its WebSocket is broken and reloading the admin UI is the fix. On the server side, `~/.maxy/logs/terminal.log` carries a `terminal-proxy-flow` heartbeat every 5 seconds while bytes are moving and every ~30 seconds while idle, with `clientBytes`, `upstreamBytes`, and `idleMs` fields — so a stuck upstream presents as `upstreamBytes` frozen and `idleMs` climbing, directly visible in a `tail -f` without any client-side evidence needed.
74
74
 
75
- The terminal also opens as a standalone header-menu surface. The burger menu in the admin chat includes a **Terminal** entry next to **Browser**; clicking it mounts a fullscreen overlay that attaches to the same `maxy-pty` tmux session. This surface is pure attach no upgrade command is sent so you can run arbitrary shell work without touching the Software Update flow. Closing the overlay (Escape or the `×` button) detaches from tmux; the session on the Pi keeps running, and re-opening re-attaches with full scrollback intact. Both surfaces can be open simultaneously and show the same session via tmux's native multi-attach. Authorisation is identical to the upgrade surface: the same `canAccessAdmin()` gate + same-origin check + public-host rejection the WebSocket proxy enforces for every client. If `ttyd` is unreachable, the overlay renders the same inline "Remote terminal not available" message with a Try again button; server-side you discriminate surfaces via `grep corrId=header-overlay ~/.maxy/logs/terminal.log` (the upgrade modal uses `corrId=upgrade-modal`).
75
+ The burger menu in the admin chat also includes a standalone **Terminal** entry next to **Browser**, but this surface is intentionally *different* from the upgrade-modal terminal. Clicking it spawns a real GUI terminal emulator (gnome-terminal on desktops that ship it, xterm elsewhere) on the VNC virtual display `:99` via the same `/vnc-viewer.html` iframe that Browser uses. The header Terminal is isomorphic to the header Browser: one pipeline (launch endpoint display spawn noVNC iframe), one failure domain — and deliberately decoupled from ttyd. Stopping `maxy-ttyd.service` leaves the header Terminal fully functional; only the in-modal upgrade terminal degrades. Closing the header Terminal overlay (Escape or the `×` button) kills the spawned emulator PID on the Pi via `POST /api/admin/terminal/close`; re-opening launches a fresh shell. Authorisation is inherited from the same `canAccessAdmin()` gate that wraps every `/api/admin/*` route. The launch endpoint logs to `~/.maxy/logs/terminal-launch.log` (script-level spawn/kill events) and to `vnc-boot.log` via `vncLog('ensure-terminal', ...)` (Node-side state machine), mirroring the Browser's `ensure-cdp` observability shape.
76
76
 
77
77
  ## AI Content Provenance
78
78
 
@@ -142,18 +142,36 @@ In `terminal.log`, look for `terminal-proxy-flow` lines — they're emitted ever
142
142
 
143
143
  **If no narration appears at all within ~10 seconds of click:** the xterm renderer or its WebSocket is broken — reload the admin UI in your browser. The absence of the self-narration is itself the diagnostic. On reload, if the preamble line does not appear again within a few hundred milliseconds of clicking Upgrade, the admin server cannot reach `ttyd`; follow the "Admin terminal not available" steps above.
144
144
 
145
- ## Terminal menu item renders an empty rectangle
145
+ ## Header Terminal click shows an error alert
146
146
 
147
- **Symptom:** You opened the burger menu, clicked **Terminal**, and the fullscreen overlay mounted but the body area stayed black for more than a second or two no prompt, no scrollback, just an empty rectangle where the shell should be.
147
+ **Symptom:** You opened the burger menu and clicked **Terminal**, but instead of the overlay appearing you got an inline error like "Terminal failed to start" or "VNC failed to start".
148
148
 
149
- **What it means:** `ttyd` on `127.0.0.1:7681` is not accepting the WebSocket, so `RemoteTerminal` cannot attach to `maxy-pty`. If `maxy-ttyd.service` is unreachable for long enough, the overlay flips to an inline "Remote terminal not available" message with a **Try again** button but before that trips you get the empty-rectangle window. The quickest check is the same one used for the Software Update path:
149
+ **What it means:** `POST /api/admin/terminal/launch` returned a 502 either the VNC stack on port 5900 is down or the terminal emulator could not be spawned on display `:99`. The header Terminal is decoupled from `ttyd`; this is a VNC-stack or display-spawn failure, not an upgrade-terminal problem.
150
+
151
+ Step-by-step diagnosis:
150
152
 
151
153
  ```bash
152
- sudo systemctl --user status maxy-ttyd
153
- sudo tail -n 50 ~/.maxy/logs/terminal.log
154
+ # 1. Check the terminal-launch log for the specific failure reason
155
+ sudo tail -n 50 ~/.maxy/logs/terminal-launch.log
156
+
157
+ # 2. Check the node-side state machine (ensure-terminal entries)
158
+ sudo grep ensure-terminal ~/.maxy/logs/vnc-boot.log | tail -20
159
+
160
+ # 3. Verify the VNC display itself is healthy
161
+ sudo ~/maxy/platform/scripts/vnc.sh status # should print "running"
162
+ DISPLAY=:99 xdpyinfo >/dev/null 2>&1 && echo "display ok" || echo "display dead"
163
+
164
+ # 4. Confirm a terminal binary is installed (xterm is the always-available fallback)
165
+ which gnome-terminal; which xterm
154
166
  ```
155
167
 
156
- Filter the log to this surface specifically: `grep corrId=header-overlay ~/.maxy/logs/terminal.log` — the Software Update window uses `corrId=upgrade-modal`, so the discriminator lets you tell which client hit the problem. A healthy open looks like `proxy-open corrId=header-overlay` followed by `proxy-flow` heartbeats every 5s; an unreachable backend looks like `proxy-error side=upstream-connect err=ECONNREFUSED`. If `maxy-ttyd` is not running, restart it with `sudo systemctl --user restart maxy-ttyd`, close the overlay, and reopen it from the burger menu — `tmux new-session -A` is idempotent so your session and scrollback come back intact.
168
+ Most common failures and fixes:
169
+
170
+ - `[terminal-launch] failed err="no terminal emulator installed"` → run `sudo apt-get install -y xterm` (or re-run the installer, which installs xterm as a dependency).
171
+ - `[terminal-launch] failed err="spawn detached but no terminal PID visible within 1s"` → X server on `:99` is wedged. `sudo systemctl --user restart maxy-ui` cycles the VNC stack via `vnc.sh start`.
172
+ - `ensure-terminal action="escalate-vnc-restart"` followed by `degraded` → `Xtigervnc` itself is not coming up. Check `~/.maxy/logs/vnc-boot.log` for the tigervnc startup lines.
173
+
174
+ The header Terminal is decoupled from `maxy-ttyd.service` by design — stopping that service should *not* break the header Terminal. If the upgrade modal breaks but the header Terminal still works, the problem is isolated to ttyd (see the "Admin Terminal Stuck Disconnected After Upgrade" and "Admin terminal not available" sections above).
157
175
 
158
176
  ## Orphan Account Directory Archived to `.trash/`
159
177
 
@@ -2,13 +2,18 @@
2
2
  # VNC + browser lifecycle — single source of truth.
3
3
  # Called by systemd ExecStartPre (boot) and lib/vnc.ts ensureVnc() (recovery).
4
4
  #
5
- # Usage: vnc.sh start | stop | start-chrome | start-chrome-native | status
5
+ # Usage: vnc.sh start | stop | start-chrome | start-chrome-native
6
+ # | start-terminal | start-terminal-native | status
6
7
  #
7
8
  # Components:
8
9
  # Xtigervnc :99 — virtual X11 display + VNC server on port 5900
9
10
  # websockify :6080 — WebSocket bridge serving noVNC static files
10
11
  # Chromium :9222 — headed browser with CDP enabled
11
12
  # (Playwright MCP connects via --cdp-endpoint)
13
+ # Terminal emulator — gnome-terminal or xterm, spawned on demand for the
14
+ # admin Terminal overlay (Task 627). Isomorphic to the
15
+ # Chromium pipeline — same :99 VNC display, same
16
+ # /vnc-viewer.html iframe surface.
12
17
  #
13
18
  # Display modes (DISPLAY_MODE env var, set by installer --display flag):
14
19
  # virtual (default) — Chromium runs on :99 (VNC virtual display)
@@ -33,13 +38,19 @@ fi
33
38
  MAXY_DIR="${HOME}/${CONFIG_DIR}"
34
39
  LOG_DIR="${MAXY_DIR}/logs"
35
40
  LOG_FILE="${LOG_DIR}/vnc-boot.log"
41
+ TERMINAL_LOG="${LOG_DIR}/terminal-launch.log"
36
42
 
37
43
  mkdir -p "$LOG_DIR"
38
44
 
39
45
  log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }
46
+ tlog() { echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] [terminal-launch] $*" >> "$TERMINAL_LOG"; }
40
47
 
41
48
  kill_stale() {
42
49
  pkill -f 'chromium.*remote-debugging-port=9222' 2>/dev/null || true
50
+ # Terminal emulators launched by start-terminal[-native] (Task 627).
51
+ # Regex is anchored to avoid killing gnome-terminal-server (D-Bus service
52
+ # always running on GNOME desktops — not ours to manage).
53
+ kill_terminal_emulators
43
54
  pkill -f 'Xtigervnc :99' 2>/dev/null || true
44
55
  pkill -f 'websockify.*6080' 2>/dev/null || true
45
56
  rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
@@ -170,6 +181,143 @@ start_chrome() {
170
181
  start_chrome_on ":99" "vnc"
171
182
  }
172
183
 
184
+ # ---------------------------------------------------------------------------
185
+ # Terminal emulator lifecycle (Task 627) — isomorphic to Chromium's.
186
+ # ---------------------------------------------------------------------------
187
+
188
+ # Resolve the preferred terminal binary + its required flags. Prefers
189
+ # gnome-terminal on desktops that ship it (Ubuntu), falls back to xterm
190
+ # (Bookworm minimum). Prints "<bin>\t<flags>" on stdout (tab-separated), or
191
+ # exits non-zero with a loud-fail log if neither is installed — matches the
192
+ # operator invariant "no ttyd fallback, no silent substitution" from Task 627.
193
+ #
194
+ # gnome-terminal needs `--wait` because its /usr/bin/gnome-terminal entry is a
195
+ # python D-Bus launcher that forks `/usr/bin/gnome-terminal.real` and would
196
+ # otherwise exit seconds after dispatch — leaving our pgrep-based liveness
197
+ # probe blind while the actual window (owned by gnome-terminal-server) is
198
+ # still on screen. With --wait, both the python wrapper and .real stay alive
199
+ # for the duration of the shell session. xterm is a single-process emulator
200
+ # and needs no flag.
201
+ resolve_terminal_bin() {
202
+ if [ -x /usr/bin/gnome-terminal ]; then
203
+ printf '%s\t%s\n' '/usr/bin/gnome-terminal' '--wait'
204
+ return 0
205
+ fi
206
+ if [ -x /usr/bin/xterm ]; then
207
+ printf '%s\t%s\n' '/usr/bin/xterm' ''
208
+ return 0
209
+ fi
210
+ tlog "failed err=\"no terminal emulator installed (expected /usr/bin/gnome-terminal or /usr/bin/xterm)\""
211
+ log "ERROR: no terminal emulator binary found — install xterm (apt-get install -y xterm)"
212
+ return 1
213
+ }
214
+
215
+ # pgrep pattern that matches operator-launched terminals but NEVER matches
216
+ # /usr/libexec/gnome-terminal-server (D-Bus service, pre-existing). Matches:
217
+ # - /usr/bin/gnome-terminal --wait (Ubuntu's python wrapper, invoked with --wait)
218
+ # - /usr/bin/python3 /usr/bin/gnome-terminal (wrapper as seen via pgrep -f)
219
+ # - /usr/bin/gnome-terminal.real --wait (actual binary, child of wrapper)
220
+ # - /usr/bin/xterm, xterm -geometry ... (xterm, single-process emulator)
221
+ # Rejects:
222
+ # - /usr/libexec/gnome-terminal-server (D-Bus service, not ours to manage)
223
+ #
224
+ # The `(^|/)` alternation anchors on either the start of the cmdline or a
225
+ # preceding `/` path separator, so the python wrapper path `/usr/bin/python3
226
+ # /usr/bin/gnome-terminal --wait` is matched via the inner `/gnome-terminal`.
227
+ # The `(\.real)?` optional suffix catches the actual binary. The trailing
228
+ # `([[:space:]]|$)` breaks the match on `-server` (dash is not whitespace).
229
+ # POSIX ERE — procps `pgrep` does not support `\s`, so use [[:space:]].
230
+ _terminal_pgrep_pattern() {
231
+ echo '(^|/)(gnome-terminal(\.real)?([[:space:]]|$)|xterm([[:space:]]|$))'
232
+ }
233
+
234
+ # Return 0 if a launched terminal is alive, 1 otherwise.
235
+ # ensureTerminal() uses this as its post-spawn liveness probe (the
236
+ # terminal-domain analogue of waitForPort — terminals have no port).
237
+ terminal_alive() {
238
+ pgrep -f "$(_terminal_pgrep_pattern)" >/dev/null 2>&1
239
+ }
240
+
241
+ # Return the first matching PID (used in logs). Empty string if none alive.
242
+ terminal_pid() {
243
+ pgrep -f "$(_terminal_pgrep_pattern)" 2>/dev/null | head -n 1
244
+ }
245
+
246
+ # Kill all operator-launched terminal emulators. Pre-existing
247
+ # gnome-terminal-server is unaffected by the anchored regex.
248
+ kill_terminal_emulators() {
249
+ pkill -f "$(_terminal_pgrep_pattern)" 2>/dev/null || true
250
+ }
251
+
252
+ # Wait up to 1s for the spawned terminal to show up in pgrep.
253
+ # Mirrors wait_for_port's deadline semantics (3 × 0.3s = 0.9s wall-clock max).
254
+ wait_for_terminal() {
255
+ for _ in 1 2 3; do
256
+ if terminal_alive; then
257
+ return 0
258
+ fi
259
+ sleep 0.3
260
+ done
261
+ return 1
262
+ }
263
+
264
+ start_terminal_on() {
265
+ local target_display="$1"
266
+ local label="$2" # "vnc" | "native"
267
+
268
+ # Idempotency: if a terminal is already alive, do not spawn another.
269
+ # Matches ensureCdp's "CDP up → return true" branch. Display-switch is
270
+ # the caller's responsibility (ensureTerminal in vnc.ts kills first).
271
+ if terminal_alive; then
272
+ local existing_pid
273
+ existing_pid="$(terminal_pid)"
274
+ tlog "already-running pid=${existing_pid} display=${target_display}"
275
+ log "Terminal already running pid=${existing_pid} (${label})"
276
+ return 0
277
+ fi
278
+
279
+ local resolved bin flags
280
+ if ! resolved="$(resolve_terminal_bin)"; then
281
+ return 1
282
+ fi
283
+ bin="${resolved%%$'\t'*}"
284
+ flags="${resolved#*$'\t'}"
285
+
286
+ log "Starting ${bin} ${flags} on ${target_display} (${label})"
287
+
288
+ # setsid -f detaches the process from our controlling terminal and this
289
+ # script's process group, so the spawned terminal survives vnc.sh exiting.
290
+ # Output redirected to the terminal-launch log (not /dev/null) so any
291
+ # spawn-time stderr is captured. Flags is unquoted on purpose so an empty
292
+ # value (xterm's case) does not produce a stray "" arg.
293
+ if [ -n "$flags" ]; then
294
+ DISPLAY="${target_display}" setsid -f "$bin" $flags >> "$TERMINAL_LOG" 2>&1 || true
295
+ else
296
+ DISPLAY="${target_display}" setsid -f "$bin" >> "$TERMINAL_LOG" 2>&1 || true
297
+ fi
298
+
299
+ if wait_for_terminal; then
300
+ local pid
301
+ pid="$(terminal_pid)"
302
+ tlog "started pid=${pid} display=${target_display} cmd=\"${bin} ${flags}\" transport=${label}"
303
+ log "Terminal ready (${label}) pid=${pid}"
304
+ return 0
305
+ else
306
+ tlog "failed err=\"spawn detached but no terminal PID visible within 1s\" transport=${label} cmd=\"${bin} ${flags}\""
307
+ log "ERROR: terminal failed to appear in pgrep within 1s on ${target_display} (${label}) — investigate setsid -f detachment / --wait flag"
308
+ return 1
309
+ fi
310
+ }
311
+
312
+ start_terminal() {
313
+ start_terminal_on ":99" "vnc"
314
+ }
315
+
316
+ start_terminal_native() {
317
+ discover_native_session
318
+ start_terminal_on "${NATIVE_DISPLAY}" "native"
319
+ }
320
+
173
321
  start_chrome_native() {
174
322
  discover_native_session
175
323
 
@@ -279,6 +427,30 @@ case "${1:-}" in
279
427
  start_chrome_native
280
428
  ;;
281
429
 
430
+ start-terminal)
431
+ start_terminal
432
+ ;;
433
+
434
+ start-terminal-native)
435
+ start_terminal_native
436
+ ;;
437
+
438
+ kill-terminal)
439
+ pid="$(terminal_pid)"
440
+ kill_terminal_emulators
441
+ if [ -n "$pid" ]; then
442
+ tlog "killed pid=${pid} reason=overlay-close"
443
+ else
444
+ tlog "killed-noop"
445
+ fi
446
+ ;;
447
+
448
+ status-terminal)
449
+ # Exit 0 if an operator-launched terminal is running, 1 otherwise.
450
+ # Used by ensureTerminal() in vnc.ts as the in-process liveness probe.
451
+ terminal_alive && exit 0 || exit 1
452
+ ;;
453
+
282
454
  stop)
283
455
  log "Stopping VNC stack"
284
456
  kill_stale
@@ -290,7 +462,7 @@ case "${1:-}" in
290
462
  ;;
291
463
 
292
464
  *)
293
- echo "Usage: vnc.sh start | stop | start-chrome | start-chrome-native | status" >&2
465
+ echo "Usage: vnc.sh start | stop | start-chrome | start-chrome-native | start-terminal | start-terminal-native | kill-terminal | status-terminal | status" >&2
294
466
  exit 1
295
467
  ;;
296
468
  esac