@hanzlaa/rcode 4.5.0 → 4.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -16,3 +16,9 @@ color: purple
16
16
  @.rcode/references/agent-shared-rules.md
17
17
  @.rcode/references/codebase-grounding.md
18
18
  @.rcode/skills/agents/mariam-marketing/SKILL.md
19
+
20
+ ## Grounding rule (mandatory)
21
+
22
+ Any pricing, fee, rate, market-size, or regulation claim MUST be verified with
23
+ WebSearch/WebFetch in-session, or explicitly tagged `[unverified — training data]`.
24
+ Do not present training-data numbers as current fact.
@@ -16,3 +16,9 @@ color: blue
16
16
  @.rcode/references/agent-shared-rules.md
17
17
  @.rcode/references/codebase-grounding.md
18
18
  @.rcode/skills/agents/sadiq-analyst/SKILL.md
19
+
20
+ ## Grounding rule (mandatory)
21
+
22
+ Any pricing, fee, rate, market-size, or regulation claim MUST be verified with
23
+ WebSearch/WebFetch in-session, or explicitly tagged `[unverified — training data]`.
24
+ Do not present training-data numbers as current fact.
@@ -18,3 +18,9 @@ color: green
18
18
  @.rcode/references/codebase-grounding.md
19
19
  @.rcode/references/karpathy-guidelines.md
20
20
  @.rcode/skills/agents/waleed-architect/SKILL.md
21
+
22
+ ## Grounding rule (mandatory)
23
+
24
+ Any pricing, fee, rate, market-size, or regulation claim MUST be verified with
25
+ WebSearch/WebFetch in-session, or explicitly tagged `[unverified — training data]`.
26
+ Do not present training-data numbers as current fact.
@@ -25,8 +25,43 @@ const fs = require('fs');
25
25
  const os = require('os');
26
26
  const path = require('path');
27
27
  const { execSync, spawnSync } = require('child_process');
28
- const { resolveActivePhase, readSprintProgress, readRecentCommits, readMilestoneHint } = require('./lib/state-reader.cjs');
29
- const { selectMemoryChunks, formatMemoryContext, hasMemory } = require('./lib/memory-select.cjs');
28
+
29
+ /**
30
+ * Self-healing lib require (#960). When this file runs from an installed
31
+ * `.rcode/bin/` whose `lib/` is stale or partial (fresh git worktree, merge/
32
+ * pull that changed `rcode/bin/lib/` without a mirror sync), a hard require
33
+ * crashes EVERY hook — the user sees a SessionStart loader error and loses
34
+ * the status line entirely. Instead: on MODULE_NOT_FOUND, try healing from
35
+ * the in-repo source of truth (`rcode/bin/lib/<name>` relative to the project
36
+ * root that contains this `.rcode/`), retry once, and otherwise fail open so
37
+ * hooks degrade (no memory injection / drift check) rather than die.
38
+ */
39
+ function requireLib(name) {
40
+ const local = path.join(__dirname, 'lib', name);
41
+ try { return require(local); } catch (err) {
42
+ if (err && err.code !== 'MODULE_NOT_FOUND') throw err;
43
+ try {
44
+ const src = path.join(__dirname, '..', '..', 'rcode', 'bin', 'lib', name);
45
+ if (fs.existsSync(src)) {
46
+ fs.mkdirSync(path.dirname(local), { recursive: true });
47
+ fs.copyFileSync(src, local);
48
+ return require(local);
49
+ }
50
+ } catch { /* healing is best-effort */ }
51
+ return null;
52
+ }
53
+ }
54
+
55
+ const _stateReader = requireLib('state-reader.cjs') || {};
56
+ const resolveActivePhase = _stateReader.resolveActivePhase || (() => ({ activePhase: null, phaseLabel: null }));
57
+ const readSprintProgress = _stateReader.readSprintProgress || (() => ({ completedCount: 0, incompleteTasks: [] }));
58
+ const readRecentCommits = _stateReader.readRecentCommits || (() => []);
59
+ const readMilestoneHint = _stateReader.readMilestoneHint || (() => null);
60
+
61
+ const _memSelect = requireLib('memory-select.cjs') || {};
62
+ const selectMemoryChunks = _memSelect.selectMemoryChunks || (() => []);
63
+ const formatMemoryContext = _memSelect.formatMemoryContext || (() => '');
64
+ const hasMemory = _memSelect.hasMemory || (() => false);
30
65
 
31
66
  // lib/memory-drift.cjs is optional at the module-load level: some hook-copy
32
67
  // test fixtures deliberately stage a minimal bin/lib/ (only state-reader.cjs)
@@ -34,9 +69,10 @@ const { selectMemoryChunks, formatMemoryContext, hasMemory } = require('./lib/me
34
69
  // require would crash every subcommand, not just `drift`/`post-commit`, so
35
70
  // this loads lazily and fails open — same pattern as INTENT_TABLE below.
36
71
  let checkDrift = null;
37
- try {
38
- ({ checkDrift } = require('./lib/memory-drift.cjs'));
39
- } catch { /* optional see comment above */ }
72
+ {
73
+ const _drift = requireLib('memory-drift.cjs');
74
+ if (_drift) ({ checkDrift } = _drift);
75
+ }
40
76
 
41
77
  /**
42
78
  * Read and parse stdin JSON.
@@ -88,6 +88,7 @@ Five named heuristics. Cite by name when reasoning:
88
88
  - **Never claim market readiness from < 4 disconfirmable signals.** Three customers is a focus group at best.
89
89
  - **Never write a launch plan** without a 90-day proof point AND the kill criterion.
90
90
  - **Never speculate on market data without WebSearch.** "unknown — would need 1 hour of research" is a valid answer.
91
+ - **Grounding rule (mandatory):** any pricing, fee, rate, market-size, or regulation claim MUST be verified with WebSearch/WebFetch in-session, or explicitly tagged `[unverified — training data]`.
91
92
  - **Never write PRDs / user stories / architecture decisions.** Stay in the GTM lane.
92
93
  - Brand consistency over clever campaigns
93
94
 
@@ -84,6 +84,7 @@ State the rule by name when refusing.
84
84
  - **Never accept urgency manufactured by sales pressure** without independent market signal. Get the LOI in writing first.
85
85
  - **Never make a strategic call under context-switch pressure.** If the user is tired or mid-fire, defer. Bad strategy at midnight is worse than no strategy.
86
86
  - **Never write code, PRDs, or research reports.** Strategy directors set bets and kill switches; that's the deliverable.
87
+ - **Grounding rule (mandatory):** any pricing, fee, rate, market-size, or regulation claim MUST be verified with WebSearch/WebFetch in-session, or explicitly tagged `[unverified — training data]`.
87
88
 
88
89
  ## In Round 2 (council follow-ups)
89
90
 
@@ -86,6 +86,7 @@ State the rule by name when refusing.
86
86
  - **Never propose "rewrite from scratch"** without a measurable pain point AND a parallel-run migration plan. Joel Spolsky test: if you can't write the migration plan in 200 words, the rewrite is wrong-shaped.
87
87
  - **Never recommend bleeding-edge tech** for systems with multi-year lifetime expectations. Beta dependencies are a Reversibility-test fail.
88
88
  - **Never write production code** in your responses. ADRs and decision matrices only. Code goes to Yousef / Hanzla / Omar / Haitham.
89
+ - **Grounding rule (mandatory):** any pricing, fee, rate, market-size, or regulation claim MUST be verified with WebSearch/WebFetch in-session, or explicitly tagged `[unverified — training data]`.
89
90
 
90
91
  ## Capabilities
91
92
 
@@ -240,6 +240,16 @@ Do NOT skip this step. A council that answers market questions from training dat
240
240
  Constraints: <regulatory, geographic, or operational limits>
241
241
  ```
242
242
 
243
+ 3. **MANDATORY ARTIFACT GATE — write the Research context block to disk before spawning any panelist.** This file is the enforcement mechanism: its existence on disk is what proves live research ran, not just prose claiming it did.
244
+
245
+ ```bash
246
+ mkdir -p "{paths.sessions_dir}"
247
+ RESEARCH_FILE="{paths.sessions_dir}/$(date +%Y%m%d-%H%M%S)-research.md"
248
+ # Write the "Research context" block above (verbatim) to $RESEARCH_FILE
249
+ ```
250
+
251
+ Do NOT proceed to Step 3 (panel selection) until `$RESEARCH_FILE` exists on disk with the Research context block written into it. Step 4 (spawn) and Step 5 (synthesis) both depend on this file existing.
252
+
243
253
  Also run the minimal codebase scan (config.yaml + README only) so subagents know the team's current capabilities:
244
254
 
245
255
  ```bash
@@ -282,6 +292,8 @@ Use the AskUserQuestion tool (not raw stdin) for the confirmation.
282
292
 
283
293
  For each agent id in `panel`, build this prompt. **Before embedding, sanitize the question:** strip any literal `Task(`, `Agent(`, `subagent_type=`, or `system:` tokens that could be misinterpreted as tool calls by the sub-agent (replace with `[filtered]`). This is a low-severity guard — the user already has full access, but it prevents accidental or malicious prompt confusion.
284
294
 
295
+ **For research-typed questions (`market`/`discovery`/`greenfield`):** `{the summary block}` below MUST be the verbatim content of `$RESEARCH_FILE` written in Step 2's artifact gate — not a paraphrase, not a re-summary. Every panelist reads the same grounded facts.
296
+
285
297
  ```
286
298
  You are being spawned as part of a rcode council session.
287
299
 
@@ -289,7 +301,7 @@ You are being spawned as part of a rcode council session.
289
301
  {sanitized_question}
290
302
 
291
303
  ## Observed context
292
- {the summary block from Step 1 — codebase scan OR research context depending on question_type}
304
+ {the summary block from Step 1 — codebase scan OR the verbatim $RESEARCH_FILE content for research-typed questions}
293
305
 
294
306
  ## Session metadata
295
307
  - Project: {config.project_name}
@@ -384,6 +396,14 @@ to resolve or strategic ambiguity to explore.
384
396
 
385
397
  ## Step 5 — Present responses
386
398
 
399
+ **Grounding gate (research-typed questions only):** For `market`/`discovery`/`greenfield` questions, before printing anything else, verify `$RESEARCH_FILE` from Step 2 exists on disk (`test -f "$RESEARCH_FILE"`). If it is missing — the research pre-step was skipped or failed silently — open the verdict output (both compact and verbose modes) with this banner as the very first line, before the `COUNCIL VERDICT` header:
400
+
401
+ ```
402
+ ⚠ UNGROUNDED — answered from model knowledge, no live research ran
403
+ ```
404
+
405
+ Do NOT silently proceed as if research happened. This banner is mandatory whenever the file is missing for a research-typed question; it is never shown for `codebase`/`frontend`/`backend`/etc. questions where no research file is expected.
406
+
387
407
  Before saving any artifact, print the panel output inline. Two modes:
388
408
 
389
409
  ### Default mode (compact summary)
@@ -417,6 +437,9 @@ Format:
417
437
  **Orchestrator note**
418
438
  {max 2 sentences — sharpest remaining disagreement OR clearest convergent action}
419
439
 
440
+ **Data freshness**
441
+ {N} claims live-verified (sources: {comma-separated source names/URLs}) / {M} claims from model knowledge
442
+
420
443
  📄 Full transcripts: {artifact path}
421
444
  ```
422
445
 
@@ -425,6 +448,7 @@ Rules for compact mode:
425
448
  - Convergence table: 2-5 rows, only axes where panelists take a stance. Cells ≤ 6 words.
426
449
  - Round 2 deltas: ≤ 15 words each. "Held position" is a valid delta.
427
450
  - No section headers beyond the four above. No numbered story breakdowns. No tables from panelists verbatim.
451
+ - **Data freshness footer is mandatory on every synthesis**, not just research-typed questions. Count claims across all panelist responses: a claim is "live-verified" if it cites a source found via WebSearch/WebFetch in this session (Step 2's research file, or a panelist's own in-session lookup); everything else — including anything tagged `[unverified — training data]` by a panelist — counts toward "from model knowledge". For non-research question types with no external claims, use `0 claims live-verified / 0 from model knowledge — no external claims made`.
428
452
 
429
453
  ### Verbose mode (`--verbose` flag or `output.verbose: true` in config)
430
454
 
@@ -449,6 +473,8 @@ Print Round 1 (and Round 2 if ran) verbatim in panel order. Do NOT summarize.
449
473
 
450
474
  ---
451
475
  **Orchestrator Note:** {max 3 sentences}
476
+
477
+ **Data freshness:** {N} claims live-verified (sources) / {M} claims from model knowledge
452
478
  ```
453
479
 
454
480
  Before presenting, load the commit format reference:
@@ -575,6 +601,8 @@ node .rcode/bin/rcode-tools.cjs state record-session
575
601
  - [ ] Round 2 cross-talk executed (unless consensus or agent deferred)
576
602
  - [ ] Session artifact written to `.planning/council-sessions/council-{date}-{slug}.md`
577
603
  - [ ] State updated with session record and timestamp
604
+ - [ ] For research-typed questions (`market`/`discovery`/`greenfield`): `$RESEARCH_FILE` written before spawn, or ⚠ UNGROUNDED banner shown
605
+ - [ ] Data freshness footer included in synthesis output
578
606
 
579
607
  ## On Error
580
608
 
@@ -36,6 +36,9 @@ const { renderHtml } = require('./lib/html/shell');
36
36
 
37
37
  // ---------- Configuration ----------
38
38
  const PORT = parseInt(process.env.PORT || '7717', 10);
39
+ // #969 — the orchestrator's actual port, injected into the client so it never
40
+ // has to hardcode 7718. Defaults match orchestrator.js's own default.
41
+ const ORCH_PORT = parseInt(process.env.ORCH_PORT || '7718', 10);
39
42
  const RCODE_DIR = process.env.RCODE_DIR || path.join(process.cwd(), '.rcode');
40
43
  const PROJECT_ROOT = path.dirname(RCODE_DIR);
41
44
  // Fallback root for agent prompts when rcode is installed as a package (not run
@@ -147,7 +150,7 @@ function handleRequest(req, res) {
147
150
  return;
148
151
  }
149
152
  res.writeHead(200, { 'Content-Type': 'application/json' });
150
- res.end(JSON.stringify({ token: ORCH_TOKEN }));
153
+ res.end(JSON.stringify({ token: ORCH_TOKEN, orchPort: ORCH_PORT }));
151
154
  return;
152
155
  }
153
156
 
@@ -178,7 +181,7 @@ function handleRequest(req, res) {
178
181
 
179
182
  if (url === '/' || url === '/index.html') {
180
183
  const state = scanState(RCODE_DIR);
181
- const html = renderHtml(state, ORCH_TOKEN);
184
+ const html = renderHtml(state, ORCH_TOKEN, ORCH_PORT);
182
185
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
183
186
  res.end(html);
184
187
  return;
@@ -257,7 +260,7 @@ function spawnOrchestrator() {
257
260
  console.error('[orch] spawn error:', err.message);
258
261
  _orchProc = null;
259
262
  });
260
- console.log('[orch] orchestrator started (port 7718)');
263
+ console.log(`[orch] orchestrator started (port ${ORCH_PORT})`);
261
264
  } catch (err) {
262
265
  console.error('[orch] failed to start:', err.message);
263
266
  }
@@ -304,7 +304,7 @@ export function App() {
304
304
  />
305
305
  </div>
306
306
 
307
- <${XtermPanel} />
307
+ <${XtermPanel} suspend=${view === 'orchestration'} />
308
308
  <${OrchPanel} />
309
309
  <${BlockedToasts} />
310
310
  <${RunnerPicker} />
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
17
17
  import { useStore, setState } from '../store.js';
18
- import { orchToken, stopSession, cleanSessions, ORCH_WS } from '../orchestrator.js';
18
+ import { orchToken, stopSession, cleanSessions, orchWs } from '../orchestrator.js';
19
19
  import { showToast } from './shared.js';
20
20
  import { Icon } from '../icons-client.js';
21
21
 
@@ -106,7 +106,7 @@ export function OrchPanel() {
106
106
  return;
107
107
  }
108
108
  const ws = new WebSocket(
109
- ORCH_WS + '/ws/' + encodeURIComponent(storyId) +
109
+ orchWs() + '/ws/' + encodeURIComponent(storyId) +
110
110
  '?token=' + encodeURIComponent(tok)
111
111
  );
112
112
  _streams[storyId] = ws;
@@ -9,18 +9,34 @@
9
9
  *
10
10
  * Store field: state.terminal = { open, storyId, title, minimized, fullscreen }
11
11
  * Setting state.terminal via orchestrator.js triggers this component.
12
+ *
13
+ * Two mount points, one singleton terminal:
14
+ * - App.js mounts one instance as a floating overlay (backdrop + sliding
15
+ * panel + minimized pill) on every view.
16
+ * - OrchestrationView.js mounts a second instance with `docked=true` to
17
+ * embed the SAME xterm.js Terminal inline in its right column.
18
+ * Only one instance may touch the DOM at a time — App.js passes
19
+ * `suspend=${view === 'orchestration'}` so its overlay instance goes fully
20
+ * inert (renders null, effects no-op) while Orchestration's docked instance
21
+ * is mounted. `ensureTerm()` reparents the shared xterm DOM node into
22
+ * whichever container asks for it, so the buffer/connection survive the
23
+ * hand-off in both directions.
12
24
  */
13
25
 
14
26
  import { html, useEffect, useRef, useCallback } from '../preact.js';
15
27
  import { useStore, setState } from '../store.js';
16
- import { orchToken, stopSession, ORCH_WS } from '../orchestrator.js';
28
+ import { orchToken, stopSession, orchWs } from '../orchestrator.js';
17
29
 
18
30
  // ── Internal state (module-scoped, one panel at a time) ──────────────────────
19
- // These refs are NOT component state because the xterm instance must persist
20
- // across panel open/close cycles and Preact re-renders.
21
- let _term = null;
22
- let _termFit = null;
23
- let _termWs = null;
31
+ // These are NOT component state because the xterm instance (and the story it
32
+ // is currently connected to) must persist across panel open/close cycles,
33
+ // Preact re-renders, and — now — across the two XtermPanel mount points
34
+ // (floating overlay vs. docked). Component-local refs would not be shared
35
+ // between those two instances.
36
+ let _term = null;
37
+ let _termFit = null;
38
+ let _termWs = null;
39
+ let _currentStory = null;
24
40
 
25
41
  function setStatus(dotStatus) {
26
42
  // Propagate connection status via a store signal so the pill/header can react
@@ -34,9 +50,23 @@ function _resize() {
34
50
  }
35
51
  }
36
52
 
37
- /** Build the xterm instance exactly once; attach to `containerEl`. */
53
+ /**
54
+ * Build the xterm instance exactly once; attach to `containerEl`.
55
+ * If the instance already exists but lives under a DIFFERENT container
56
+ * (e.g. the overlay panel had it, and the docked panel is now asking), move
57
+ * its root DOM node into `containerEl` instead of no-oping. xterm.js's root
58
+ * element is a plain DOM node — reparenting it is safe and preserves the
59
+ * scrollback buffer and any live WebSocket connection.
60
+ */
38
61
  function ensureTerm(containerEl) {
39
- if (_term || typeof Terminal === 'undefined') return;
62
+ if (_term) {
63
+ if (_term.element && _term.element.parentElement !== containerEl) {
64
+ containerEl.appendChild(_term.element);
65
+ if (_termFit) { try { _termFit.fit(); } catch (_e) {} }
66
+ }
67
+ return;
68
+ }
69
+ if (typeof Terminal === 'undefined') return;
40
70
  _term = new Terminal({
41
71
  theme: {
42
72
  background: '#0c0c0e', foreground: '#c9d1d9',
@@ -74,7 +104,7 @@ function connectWs(storyId) {
74
104
  return;
75
105
  }
76
106
  setStatus('connecting');
77
- const url = ORCH_WS + '/ws/' + encodeURIComponent(storyId) + '?token=' + encodeURIComponent(tok);
107
+ const url = orchWs() + '/ws/' + encodeURIComponent(storyId) + '?token=' + encodeURIComponent(tok);
78
108
  const ws = new WebSocket(url);
79
109
  _termWs = ws;
80
110
 
@@ -98,10 +128,9 @@ function connectWs(storyId) {
98
128
 
99
129
  // ── Component ─────────────────────────────────────────────────────────────────
100
130
 
101
- export function XtermPanel() {
131
+ export function XtermPanel({ docked = false, suspend = false } = {}) {
102
132
  const { terminal, termStatus } = useStore();
103
133
  const containerRef = useRef(null);
104
- const currentStoryRef = useRef(null);
105
134
 
106
135
  const t = terminal || {};
107
136
  const open = !!t.open;
@@ -110,28 +139,37 @@ export function XtermPanel() {
110
139
  const storyId = t.storyId || '';
111
140
  const title = t.title || 'Terminal';
112
141
 
113
- // Build xterm instance on first open; reconnect when storyId changes.
114
- // The resize listener is registered here (not inside ensureTerm) so the
115
- // cleanup return can mirror it on unmount.
142
+ // Build/attach the xterm instance on open; (re)connect only when the
143
+ // focused storyId actually changes. `_currentStory` is module-scoped (not
144
+ // a per-instance ref) so that handing the terminal off between the
145
+ // floating overlay and the docked panel — same storyId, different
146
+ // container — reparents via ensureTerm() without tearing down the
147
+ // connection or clearing the buffer. `suspend` is in the dep array so the
148
+ // OTHER (un-suspending) instance re-runs this effect and reclaims the
149
+ // terminal DOM node when the user navigates away from Orchestration.
116
150
  useEffect(() => {
117
- if (!open || !containerRef.current) return;
151
+ if (suspend || !open || !containerRef.current) return;
118
152
  ensureTerm(containerRef.current);
119
- if (_term) { _term.clear(); _resize(); }
120
- if (storyId && storyId !== currentStoryRef.current) {
121
- currentStoryRef.current = storyId;
153
+ const isNewSession = storyId && storyId !== _currentStory;
154
+ if (isNewSession) {
155
+ _currentStory = storyId;
156
+ if (_term) _term.clear();
122
157
  connectWs(storyId);
123
158
  }
159
+ _resize();
124
160
  window.addEventListener('resize', _resize);
125
161
  return () => window.removeEventListener('resize', _resize);
126
- }, [open, storyId]);
162
+ }, [open, storyId, suspend]);
127
163
 
128
164
  // Resize when entering/leaving fullscreen or on open
129
165
  useEffect(() => {
130
- if (open) { setTimeout(_resize, 50); }
131
- }, [open, fullscreen]);
166
+ if (!suspend && open) { setTimeout(_resize, 50); }
167
+ }, [open, fullscreen, suspend]);
132
168
 
133
- // Escape key closes
169
+ // Escape key closes (docked panel has no "close" concept — it just shows
170
+ // the empty state when store.terminal is cleared elsewhere)
134
171
  useEffect(() => {
172
+ if (suspend || docked) return;
135
173
  function onKey(e) {
136
174
  if (e.key === 'Escape' && open && !minimized) {
137
175
  setState({ terminal: { ...t, open: false } });
@@ -139,9 +177,11 @@ export function XtermPanel() {
139
177
  }
140
178
  window.addEventListener('keydown', onKey);
141
179
  return () => window.removeEventListener('keydown', onKey);
142
- }, [open, minimized, t]);
180
+ }, [open, minimized, t, suspend, docked]);
143
181
 
144
182
  const dotCls = 'term-status-dot ' + (termStatus || '');
183
+ // Statuses that mean "output is actively streaming" for the docked live pulse.
184
+ const isLive = open && ['running', 'connecting', 'blocked', 'waiting'].includes(termStatus);
145
185
 
146
186
  // ── Actions ──
147
187
  const handleMinimize = useCallback(() => {
@@ -167,6 +207,41 @@ export function XtermPanel() {
167
207
  setTimeout(_resize, 50);
168
208
  }, [t, fullscreen]);
169
209
 
210
+ // Fully inert while the sibling instance owns the terminal DOM — no
211
+ // backdrop, no panel, no pill, nothing rendered at all.
212
+ if (suspend) return null;
213
+
214
+ // ── Docked render (Orchestration view's right column) ──
215
+ if (docked) {
216
+ return html`
217
+ <div class="orch-term-dock">
218
+ <div class="orch-term-dock-header">
219
+ <span class="orch-term-dot red"></span>
220
+ <span class="orch-term-dot amber"></span>
221
+ <span class="orch-term-dot green"></span>
222
+ <span class="orch-term-dock-label">xterm${open ? ' · ' + title : ''}</span>
223
+ ${isLive ? html`
224
+ <span class="orch-term-dock-live">
225
+ <span class="orch-term-dock-live-dot"></span>live
226
+ </span>
227
+ ` : null}
228
+ ${open ? html`
229
+ <button class="orch-term-dock-stop" onClick=${handleStop} title="End the agent session">Stop</button>
230
+ ` : null}
231
+ </div>
232
+ <div class="orch-term-dock-body">
233
+ ${open
234
+ ? html`<div ref=${containerRef} class="orch-term-dock-container"></div>`
235
+ : html`
236
+ <div class="orch-term-dock-empty">
237
+ No active execution. Select a command from the Runner picker to begin.
238
+ </div>
239
+ `}
240
+ </div>
241
+ </div>
242
+ `;
243
+ }
244
+
170
245
  // ── Pill (minimized state) ──
171
246
  const pill = html`
172
247
  <div
@@ -5,17 +5,27 @@
5
5
  * Preact store (activeSessions field). Components import these functions
6
6
  * directly; no window.* globals needed after Sprint 31.4.
7
7
  *
8
- * Constants
9
- * ORCH_HTTP — base URL for orchestrator REST API
10
- * ORCH_WS — base URL for orchestrator WebSocket
8
+ * Functions
9
+ * orchHttp() — base URL for orchestrator REST API
10
+ * orchWs() — base URL for orchestrator WebSocket
11
11
  */
12
12
 
13
13
  import { getState, setState } from './store.js';
14
14
  import { showToast } from './components/shared.js';
15
15
  import { trackBlocked } from './notify.js';
16
16
 
17
- export const ORCH_HTTP = 'http://localhost:7718';
18
- export const ORCH_WS = 'ws://localhost:7718';
17
+ // #969 the orchestrator port is injected by the server (see shell.js) as
18
+ // window.__ORCH_PORT__, since a dashboard started with ORCH_PORT set (e.g. a
19
+ // second instance under test) spawns its orchestrator on a non-default port.
20
+ // A hardcoded 7718 here would silently drive the wrong orchestrator process.
21
+ // Resolved per-call (not cached at module load) so it works even if a caller
22
+ // loads this module before the inline bootstrap script has run.
23
+ function orchPort() {
24
+ return (typeof window !== 'undefined' && window.__ORCH_PORT__) || 7718;
25
+ }
26
+
27
+ export function orchHttp() { return 'http://localhost:' + orchPort(); }
28
+ export function orchWs() { return 'ws://localhost:' + orchPort(); }
19
29
 
20
30
  // ── Token helpers ─────────────────────────────────────────────────────────────
21
31
 
@@ -31,7 +41,10 @@ export function orchToken() {
31
41
  export function refreshOrchToken() {
32
42
  return fetch('/api/orch-token')
33
43
  .then(r => r.json())
34
- .then(d => { if (d && d.token) window.__ORCH_TOKEN__ = d.token; })
44
+ .then(d => {
45
+ if (d && d.token) window.__ORCH_TOKEN__ = d.token;
46
+ if (d && d.orchPort) window.__ORCH_PORT__ = d.orchPort;
47
+ })
35
48
  .catch(() => {});
36
49
  }
37
50
 
@@ -50,7 +63,7 @@ export function runSession(storyId, cmd, opts) {
50
63
  body.runner = opts.runner;
51
64
  if (opts.model) body.model = opts.model;
52
65
  }
53
- return fetch(ORCH_HTTP + '/api/run', {
66
+ return fetch(orchHttp() + '/api/run', {
54
67
  method: 'POST',
55
68
  headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
56
69
  body: JSON.stringify(body),
@@ -67,7 +80,7 @@ let _runnersPromise = null;
67
80
  export function fetchRunners() {
68
81
  if (_runnersPromise) return _runnersPromise;
69
82
  const tok = orchToken();
70
- _runnersPromise = fetch(ORCH_HTTP + '/api/runners', {
83
+ _runnersPromise = fetch(orchHttp() + '/api/runners', {
71
84
  headers: { 'Authorization': 'Bearer ' + tok },
72
85
  })
73
86
  .then(r => r.json())
@@ -81,7 +94,7 @@ export function fetchRunners() {
81
94
  */
82
95
  export function stopSession(storyId) {
83
96
  const tok = orchToken();
84
- return fetch(ORCH_HTTP + '/api/stop', {
97
+ return fetch(orchHttp() + '/api/stop', {
85
98
  method: 'POST',
86
99
  headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
87
100
  body: JSON.stringify({ storyId }),
@@ -96,7 +109,7 @@ export function stopSession(storyId) {
96
109
  function fetchSessionsWithStatus() {
97
110
  const tok = orchToken();
98
111
  if (!tok) return Promise.resolve({ ok: false, sessions: [] });
99
- return fetch(ORCH_HTTP + '/api/sessions', {
112
+ return fetch(orchHttp() + '/api/sessions', {
100
113
  headers: { 'Authorization': 'Bearer ' + tok },
101
114
  })
102
115
  .then(r => {
@@ -119,7 +132,7 @@ export function fetchSessions() {
119
132
  export function fetchHistory() {
120
133
  const tok = orchToken();
121
134
  if (!tok) return Promise.resolve([]);
122
- return fetch(ORCH_HTTP + '/api/history', { headers: { 'Authorization': 'Bearer ' + tok } })
135
+ return fetch(orchHttp() + '/api/history', { headers: { 'Authorization': 'Bearer ' + tok } })
123
136
  .then(r => {
124
137
  if (r.status === 401) { refreshOrchToken(); return []; }
125
138
  return r.json().then(d => (d && d.history) || []);
@@ -160,7 +173,7 @@ export function isOrchOnline() {
160
173
  */
161
174
  export function submitRejection(storyId, reason, phase) {
162
175
  const tok = orchToken();
163
- return fetch(ORCH_HTTP + '/api/reject', {
176
+ return fetch(orchHttp() + '/api/reject', {
164
177
  method: 'POST',
165
178
  headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
166
179
  body: JSON.stringify({ storyId, reason, phase: phase || null }),
@@ -173,7 +186,7 @@ export function submitRejection(storyId, reason, phase) {
173
186
  export function fetchRejections() {
174
187
  const tok = orchToken();
175
188
  if (!tok) return Promise.resolve([]);
176
- return fetch(ORCH_HTTP + '/api/rejections', { headers: { 'Authorization': 'Bearer ' + tok } })
189
+ return fetch(orchHttp() + '/api/rejections', { headers: { 'Authorization': 'Bearer ' + tok } })
177
190
  .then(r => r.ok ? r.json().then(d => (d && d.rejections) || []) : [])
178
191
  .catch(() => []);
179
192
  }
@@ -184,7 +197,7 @@ export function fetchRejections() {
184
197
  */
185
198
  export function setTaskStatus(storyId, status) {
186
199
  const tok = orchToken();
187
- return fetch(ORCH_HTTP + '/api/task-status', {
200
+ return fetch(orchHttp() + '/api/task-status', {
188
201
  method: 'POST',
189
202
  headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
190
203
  body: JSON.stringify({ storyId, status }),
@@ -197,7 +210,7 @@ export function setTaskStatus(storyId, status) {
197
210
  */
198
211
  export function cleanSessions(olderThanDays = 0) {
199
212
  const tok = orchToken();
200
- return fetch(ORCH_HTTP + '/api/clean-sessions', {
213
+ return fetch(orchHttp() + '/api/clean-sessions', {
201
214
  method: 'POST',
202
215
  headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
203
216
  body: JSON.stringify({ olderThanDays }),