peaks-loop 4.0.9 → 4.0.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.10 — 2026-08-04 (path-canonicalize + statusline-read-isolation)
4
+
5
+ **Windows statusline fixed.** `peaks-loop@4.0.9` always rendered `peaks empty` on Windows Git Bash. Root cause: the session-binding reader used strict `===` to compare `projectRoot`; the binding had been written with backslashes (`C:\Users\...`) but `peaks skill presence:set --project C:/Users/...` arrived with forward slashes, and Node treats them as distinct strings. The 4.0.8 fail-closed `PEAKS_SESSION_NOT_BOUND` gate then blocked the presence marker write, and the statusline never had a real skill to display.
6
+
7
+ **Cross-platform path canonicalization** (5 rid: `5ae2fa6d` / `e8b467d8` / `4e22ce39` / `033df6f6` / `8da617de`):
8
+ - `src/services/session/session-manager.ts` + `src/services/session/session-binding-bridge.ts` — `readSessionFile` / `writeSessionFile` use the canonical `projectRootsMatch` (stableRealPath + normalizePath + isWindows case-fold).
9
+ - `src/shared/path-utils.ts` — `projectRootsMatch` lifted from session-manager to be the single source of truth for cross-platform path comparison.
10
+ - 17 out-of-scope `replace(/\\/g, '/')` sites consolidated to `path-utils.normalizePath`. The remaining 1 site (`karpathy-service.ts:222`) is a reverse escape (`\\` → `\\\\`) and is intentionally out of scope.
11
+ - Hard rule entered `.peaks/standards/common/coding-style.md`: no strict `===` on filesystem paths, no hand-rolled `realpathSync` in non-`path-utils` modules, no `path.replace(/\\/g, '/')` in new code. Sediment at `.peaks/memory/2026-08-04-cross-platform-path-utility-rule.md`.
12
+
13
+ **Statusline read-side isolation** (1 rid: `8da617de`):
14
+ - `peaks statusline` now reads from `active-skill-resolver.resolveActiveSkillForCaller` (canonical lease walk) instead of the project-level single file. callerId extracted from `stdin.caller_id` → `CLAUDE_CODE_SESSION_ID` env → null fallback.
15
+ - Multi-session isolation: `peaks skill presence:set` in session A no longer leaks into session B's statusline.
16
+ - Active leaf display: statusline now shows `${leaf} (+N-1) | peaks-code [mode]` when sub-agents are in flight (e.g. `peaks-rd | peaks-code [full-auto]`). The 14→1 bee skill → `peaks-code` mapping in the renderer is removed.
17
+ - **Bug fix discovered and closed during rid-005**: `active-skill-resolver.ts` was reading from the session directory root expecting `presence-*.json` files, but `presence-lease-service.ts` actually writes under `leases/` subdir. Option A fix: resolver now reads from the canonical `leases/` subdir via `listPresenceLeases`.
18
+
19
+ **Shorter animation periods** (2 rid: `52a968f7` / `532bd1b1`):
20
+ - `MARQUEE_PERIOD_MS` 2000 → 800 → 400 (statusline scan band round trip)
21
+ - `BREATHING_PERIOD_MS` 2400 → 1200 → 600 (breathing glyph rotation)
22
+ - `MARQUEE_BAND_WIDTH` 5 → 3 → 2 (highlight band width)
23
+ - At 400ms / 600ms periods, 0.5-1s turn intervals produce visibly different breathing glyph + band position on every statusline render.
24
+
25
+ **Tests.** 88/88 statusline vitest green; 47 path-canonicalize test cases green across 4 test files. ESM repros validate 0.5-1.5s turn-interval phase difference at the production level.
26
+
27
+ **Lockstep bump.** peaks-loop-shared `0.0.39 → 0.0.40` (CLI_VERSION re-stamped to 4.0.10).
28
+
3
29
  ## 4.0.9 — 2026-08-04 (statusline-marquee)
4
30
 
5
31
  **statusline: brand purple + scan-band marquee + NO_COLOR support.**
@@ -30,6 +30,7 @@ import { addJsonOption, printResult } from '../cli-helpers.js';
30
30
  import { findProjectRoot } from '../../services/config/config-safety.js';
31
31
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
32
32
  import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
33
+ import { normalizePath } from '../../shared/path-utils.js';
33
34
  import { containerLeaseFilePath, deserializeContainerLease, finalizeContainerLease, generateContainerLeaseId, markContainerReleased, ttlForContainerRole } from '../../services/container/container-lease.js';
34
35
  const DEFAULT_DOCKER_IMAGE = 'node:22-slim';
35
36
  function detectContainerRuntime(explicit) {
@@ -114,7 +115,7 @@ export function registerContainerCommand(program, io) {
114
115
  // `--label peaks.leaseId=<id>` lets `peaks container
115
116
  // list` / `peaks container gc` find orphans by label
116
117
  // when the lease file is missing.
117
- const cidFile = `${joinPathSession(projectRoot, sessionId).replace(/\\/g, '/')}/.${runtime.runtime}-cid-${leaseId}`;
118
+ const cidFile = `${normalizePath(joinPathSession(projectRoot, sessionId))}/.${runtime.runtime}-cid-${leaseId}`;
118
119
  try {
119
120
  execSync(`${runtime.runtime} run --rm -d --cidfile "${cidFile}" --label "peaks.leaseId=${leaseId}" --label "peaks.rid=${options.rid}" -v "${mount}:/work" -w /work ${image} sleep infinity`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
120
121
  }
@@ -10,6 +10,31 @@ import { getSessionId, setSessionMeta } from '../../../services/session/session-
10
10
  import { resolveCallerProjection } from '../../../services/session/resolve-caller-id.js';
11
11
  import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
12
12
  import { fail, ok } from 'peaks-loop-shared/result';
13
+ import { stableRealPath } from '../../../shared/path-utils.js';
14
+ /**
15
+ * Canonicalize a user-supplied `--project <path>` value.
16
+ *
17
+ * Git Bash on Windows hands us forward-slash paths
18
+ * (`C:/Users/.../peaks-loop`) while `peaks workspace init` writes the
19
+ * backslash form, and either side may carry a trailing separator or
20
+ * differing case. Resolving to the real path here means every
21
+ * downstream consumer (`getSessionId`, `setSessionMeta`,
22
+ * `setSkillPresence`) sees one stable form.
23
+ *
24
+ * Returns the input unchanged when it cannot be resolved (path does
25
+ * not exist yet, or is not readable) so a bad `--project` still
26
+ * reaches the existing error handling rather than throwing here.
27
+ */
28
+ function canonicalizeProjectOption(project) {
29
+ if (project === undefined)
30
+ return undefined;
31
+ try {
32
+ return stableRealPath(project);
33
+ }
34
+ catch {
35
+ return project;
36
+ }
37
+ }
13
38
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
14
39
  // Slice S0 (4.0.0-beta.5 peaks-solo dispatcher release):
15
40
  // `peaks skill search` is the CLI primitive that feeds the
@@ -143,7 +168,8 @@ export function registerSkillCommand(program, io) {
143
168
  .description('Show the currently active Peaks skill (alias: presence:get)')
144
169
  .option('--check-stale', 'slice 002 (v2.15.0): also report whether the recorded outer session id still matches the current one. Default false (back-compat).')
145
170
  .option('--project <path>', 'project root (default: cwd)')).action((options) => {
146
- const presence = getSkillPresence(options.project);
171
+ const projectOption = canonicalizeProjectOption(options.project);
172
+ const presence = getSkillPresence(projectOption);
147
173
  if (presence === null) {
148
174
  printResult(io, ok('skill.presence', { active: false }), options.json);
149
175
  return;
@@ -154,7 +180,7 @@ export function registerSkillCommand(program, io) {
154
180
  // pieces of info from a single CLI invocation. The presence
155
181
  // is returned UNCHANGED — `--check-stale` is a read-only flag,
156
182
  // not a clear.
157
- const staleness = checkStalePresence({ projectRootOverride: options.project });
183
+ const staleness = checkStalePresence({ projectRootOverride: projectOption });
158
184
  printResult(io, ok('skill.presence', {
159
185
  active: true,
160
186
  ...presence,
@@ -173,7 +199,8 @@ export function registerSkillCommand(program, io) {
173
199
  .option('--mode <mode>', 'execution mode')
174
200
  .option('--gate <gate>', 'current gate')
175
201
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((name, options) => {
176
- const projectRoot = options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
202
+ const projectOption = canonicalizeProjectOption(options.project);
203
+ const projectRoot = projectOption ?? findProjectRoot(process.cwd()) ?? process.cwd();
177
204
  if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
178
205
  printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
179
206
  process.exitCode = 1;
@@ -198,11 +225,11 @@ export function registerSkillCommand(program, io) {
198
225
  catch (err) {
199
226
  const message = err instanceof Error ? err.message : String(err);
200
227
  printResult(io, fail('skill.presence:set', 'PEAKS_CALLER_NOT_RESOLVED', `Active IDE adapter could not resolve a callerId (RD §3 D1): ${message}`, { projectRoot, name }, ['Ensure the active IDE is detected by `peaks` and the IDE session variable is set.',
201
- 'Or pass PEAKS_CALLER_ID=<id> or --caller-id <id> for scripted usage.']), options.json);
228
+ 'Or set PEAKS_CALLER_ID=<id> in the environment for scripted usage.']), options.json);
202
229
  process.exitCode = 1;
203
230
  return;
204
231
  }
205
- const presence = setSkillPresence(name, options.mode, options.gate, options.project);
232
+ const presence = setSkillPresence(name, options.mode, options.gate, projectOption);
206
233
  // Session metadata is updated when a session is bound (read-only
207
234
  // path: `getSessionId`). We do not auto-spawn a session.
208
235
  if (boundSessionId !== null) {
@@ -9,11 +9,12 @@ import { proposeFromDoctor } from '../../services/openspec/openspec-propose-from
9
9
  import { runDoctor } from '../../services/doctor/index.js';
10
10
  import { fail, ok } from 'peaks-loop-shared/result';
11
11
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
12
+ import { normalizePath } from '../../shared/path-utils.js';
12
13
  function resolveScanOptions(project) {
13
14
  if (project === undefined) {
14
15
  return {};
15
16
  }
16
- return { openspecRoot: `${project.replace(/\\/g, '/').replace(/\/$/, '')}/openspec` };
17
+ return { openspecRoot: `${normalizePath(project).replace(/\/$/, '')}/openspec` };
17
18
  }
18
19
  async function loadRenderRequest(requestPath) {
19
20
  const raw = await readFile(requestPath, 'utf8');
@@ -28,6 +28,16 @@ export interface ActiveSkillResolution {
28
28
  readonly skill: string | null;
29
29
  readonly callerId: string | null;
30
30
  readonly sessionId: string | null;
31
+ /**
32
+ * Mode token recorded on the canonical lease (e.g. `full-auto`,
33
+ * `assisted`, `swarm`, `strict`). `null` when the source is not
34
+ * `canonical` (the legacy `active-skill-*.json` files do not
35
+ * surface a mode field, and the `env` / `none` cases are test
36
+ * overrides). Slice 2026-08-04-rid-005 surfaced this so the
37
+ * statusline can render the orchestrator's mode alongside the
38
+ * active leaf role.
39
+ */
40
+ readonly mode: string | null;
31
41
  /** `canonical` = presence-lease-service; `legacy` = per-caller active-skill file; `env` = test override; `none` = nothing wired. */
32
42
  readonly source: 'env' | 'file' | 'canonical' | 'none';
33
43
  }
@@ -42,4 +52,5 @@ export interface ActiveSkillResolution {
42
52
  */
43
53
  export declare function resolveActiveSkillForCaller(projectRoot: string, opts?: {
44
54
  legacyPresence?: boolean;
55
+ callerId?: string | null;
45
56
  }): ActiveSkillResolution;
@@ -24,11 +24,11 @@
24
24
  * `legacyPresence === true` is passed by the caller
25
25
  * 4. null (caller did not set a skill; enforcers can decide to skip)
26
26
  */
27
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
27
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
  import { getSessionIdCanonical } from '../../session/session-manager.js';
30
30
  import { getSessionDir } from '../../session/getSessionDir.js';
31
- import { readPresenceLease } from '../../skills/presence-lease-service.js';
31
+ import { readPresenceLease, listPresenceLeases } from '../../skills/presence-lease-service.js';
32
32
  import { getCurrentSessionId } from '../../skills/skill-presence-service.js';
33
33
  const ACTIVE_SKILL_PREFIX = 'active-skill-';
34
34
  /**
@@ -43,71 +43,82 @@ const ACTIVE_SKILL_PREFIX = 'active-skill-';
43
43
  export function resolveActiveSkillForCaller(projectRoot, opts) {
44
44
  const envOverride = process.env.PEAKS_ACTIVE_SKILL;
45
45
  if (typeof envOverride === 'string' && envOverride.length > 0) {
46
- return { skill: envOverride, callerId: null, sessionId: null, source: 'env' };
46
+ return { skill: envOverride, callerId: null, sessionId: null, mode: null, source: 'env' };
47
47
  }
48
48
  let sessionId = null;
49
49
  try {
50
50
  sessionId = getSessionIdCanonical(projectRoot);
51
51
  }
52
52
  catch {
53
- return { skill: null, callerId: null, sessionId: null, source: 'none' };
53
+ return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
54
54
  }
55
55
  if (sessionId === null) {
56
- return { skill: null, callerId: null, sessionId: null, source: 'none' };
56
+ return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
57
57
  }
58
58
  // 4.0.8 preferred: read the canonical lease projection. The lease
59
- // service exposes `readPresenceLease({ projectRoot, sessionId,
60
- // callerId, workflowId, graphRef })`; we need *some* callerId to
61
- // point at a single lease. Hook enforcers don't have a callerId
62
- // (they're per-IDE, per-window), so we walk the canonical
63
- // presence-index to find any active lease. When the index is empty
64
- // (e.g. ad-hoc / pre-migration projects) we fall through to the
65
- // legacy walk below.
59
+ // service writes leases to `<sessionDir>/leases/presence-<caller>-<workflow>.json`
60
+ // (RD §4). We enumerate that directory via `listPresenceLeases`,
61
+ // which is the lease-service's own helper and knows the canonical
62
+ // layout. The first in-flight (status === 'preparing' | 'running')
63
+ // lease wins; this preserves the prior behaviour of returning a
64
+ // single skill per resolution. When the lease dir is empty (e.g.
65
+ // ad-hoc / pre-migration projects) we fall through to the legacy
66
+ // walk below.
66
67
  const sessionDir = getSessionDir(projectRoot, sessionId);
67
68
  if (!existsSync(sessionDir)) {
68
- return { skill: null, callerId: null, sessionId, source: 'none' };
69
+ return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
69
70
  }
70
- let entries = [];
71
+ let canonicalLeases = [];
71
72
  try {
72
- entries = readdirSync(sessionDir);
73
+ canonicalLeases = listPresenceLeases(projectRoot, sessionId);
73
74
  }
74
75
  catch {
75
- entries = [];
76
+ canonicalLeases = [];
76
77
  }
77
- const indexEntries = entries.filter((e) => !e.startsWith(ACTIVE_SKILL_PREFIX) && e.endsWith('.json') && !e.includes('.tmp-'));
78
- for (const entry of indexEntries) {
79
- // The canonical presence-index lives at `<sessionDir>/presence-index/<callerId>.json`.
80
- // We synthesise the read from any presence-* lease file we find;
81
- // the readPresenceLease call surfaces a typed `PresenceProjection`
82
- // and we map `lease.skill` → resolution.
83
- if (!entry.startsWith('presence-'))
78
+ for (const lease of canonicalLeases) {
79
+ if (lease.status !== 'preparing' && lease.status !== 'running')
84
80
  continue;
85
- const callerId = entry.slice('presence-'.length, -'.json'.length);
86
- // Derive workflowId from the same file name (canonical
87
- // leaseFilePath encodes `presence-<caller>-<workflow>.json`).
88
- const lastDash = callerId.lastIndexOf('-');
89
- if (lastDash < 0)
81
+ if (typeof lease.skill !== 'string' || lease.skill.length === 0)
82
+ continue;
83
+ // When a callerId is supplied, restrict the walk to that
84
+ // caller's lease. Slice 2026-08-04-rid-005 surfaces this for the
85
+ // statusline read so two concurrent sessions bound to the same
86
+ // project session do not see each other's skill.
87
+ if (typeof opts?.callerId === 'string' && opts.callerId.length > 0 && lease.callerId !== opts.callerId)
90
88
  continue;
91
- const workflowId = callerId.slice(lastDash + 1);
92
- const pureCaller = callerId.slice(0, lastDash);
93
89
  try {
94
90
  const projection = readPresenceLease({
95
91
  projectRoot,
96
92
  sessionId,
97
- callerId: pureCaller,
98
- workflowId,
99
- graphRef: `graphs/${workflowId}.json`,
93
+ callerId: lease.callerId,
94
+ workflowId: lease.workflowId,
95
+ graphRef: lease.graphRef,
100
96
  });
101
97
  if (projection.lease !== null && typeof projection.lease.skill === 'string' && projection.lease.skill.length > 0) {
102
- return { skill: projection.lease.skill, callerId: pureCaller, sessionId, source: 'canonical' };
98
+ return {
99
+ skill: projection.lease.skill,
100
+ callerId: lease.callerId,
101
+ sessionId,
102
+ mode: typeof projection.mode === 'string' && projection.mode.length > 0 ? projection.mode : null,
103
+ source: 'canonical',
104
+ };
103
105
  }
104
106
  }
105
- catch { /* fall through to legacy walk */ }
107
+ catch { /* fall through to next lease */ }
106
108
  }
107
109
  // Legacy fall-back (one minor release). Gated on `legacyPresence:
108
- // true` so production hooks prefer the canonical projection.
110
+ // true` so production hooks prefer the canonical projection. Reads
111
+ // the session dir root for the per-caller `active-skill-*.json`
112
+ // marker files written by the 4.0.7 `setSkillPresenceForCaller`.
109
113
  if (opts?.legacyPresence === true) {
110
- for (const entry of entries) {
114
+ let legacyEntries = [];
115
+ try {
116
+ legacyEntries = readdirSync(sessionDir);
117
+ }
118
+ catch {
119
+ legacyEntries = [];
120
+ }
121
+ for (const entry of legacyEntries) {
111
122
  if (!entry.startsWith(ACTIVE_SKILL_PREFIX) || !entry.endsWith('.json'))
112
123
  continue;
113
124
  const callerId = entry.slice(ACTIVE_SKILL_PREFIX.length, -'.json'.length);
@@ -116,7 +127,7 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
116
127
  const raw = readFileSync(filePath, 'utf8');
117
128
  const parsed = JSON.parse(raw);
118
129
  if (typeof parsed.skill === 'string' && parsed.skill.length > 0) {
119
- return { skill: parsed.skill, callerId, sessionId, source: 'file' };
130
+ return { skill: parsed.skill, callerId, sessionId, mode: null, source: 'file' };
120
131
  }
121
132
  }
122
133
  catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
@@ -129,5 +140,5 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
129
140
  // walk can still resolve a session id if the canonical resolver
130
141
  // returns null (e.g. ad-hoc projects without a `.peaks/_runtime/session.json`).
131
142
  void getCurrentSessionId;
132
- return { skill: null, callerId: null, sessionId, source: 'none' };
143
+ return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
133
144
  }
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { randomBytes } from 'node:crypto';
31
31
  import { posix as path } from 'node:path';
32
+ import { normalizePath } from '../../shared/path-utils.js';
32
33
  export const DEFAULT_TTL_BY_ROLE = Object.freeze({
33
34
  rd: 30 * 60 * 1_000,
34
35
  qa: 15 * 60 * 1_000,
@@ -126,7 +127,7 @@ export function deserializeContainerLease(raw) {
126
127
  function joinPath(...segments) {
127
128
  if (segments.length === 0)
128
129
  return '';
129
- const normalized = segments.map((s) => s.replace(/\\/g, '/'));
130
+ const normalized = segments.map((s) => normalizePath(s));
130
131
  let acc = normalized[0];
131
132
  for (let i = 1; i < normalized.length; i++) {
132
133
  acc = path.join(acc, normalized[i]);
@@ -13,6 +13,7 @@
13
13
  * command; `peaks impact must-check` returns the must-check list
14
14
  * subset. Persistence is per-invocation (no state file).
15
15
  */
16
+ import { normalizePath } from '../../shared/path-utils.js';
16
17
  /**
17
18
  * Default business flow map (covers the most common B-end
18
19
  * archetypes; users can override via --flows in the CLI).
@@ -34,7 +35,7 @@ export const DEFAULT_BUSINESS_FLOWS = new Map([
34
35
  * (the file itself, the parent dir, the project-wide pattern).
35
36
  */
36
37
  function pathToGlobs(path) {
37
- const normalized = path.replace(/\\/g, '/');
38
+ const normalized = normalizePath(path);
38
39
  const parts = normalized.split('/');
39
40
  // Drop the filename → get the parent pattern: "src/foo/" → "src/**"
40
41
  const parent = parts.length > 1 ? parts.slice(0, -1).join('/') + '/**' : '**';
@@ -42,7 +43,7 @@ function pathToGlobs(path) {
42
43
  }
43
44
  /** Simple glob matcher supporting `**` and `*`. */
44
45
  export function matchGlob(pattern, path) {
45
- const normalizedPath = path.replace(/\\/g, '/');
46
+ const normalizedPath = normalizePath(path);
46
47
  // Build regex by splitting on glob tokens, escaping the rest.
47
48
  // Order: ** (doublestar) → .* ; * → [^/]* ; other → escape
48
49
  let regexStr = '';
@@ -108,7 +109,7 @@ export function runImpactScan(opts) {
108
109
  // Predict other files that may be affected (siblings of changed files).
109
110
  const impactedFiles = [];
110
111
  for (const f of changed) {
111
- const parts = f.replace(/\\/g, '/').split('/');
112
+ const parts = normalizePath(f).split('/');
112
113
  if (parts.length <= 1)
113
114
  continue;
114
115
  const parent = parts.slice(0, -1).join('/');
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
21
21
  import { join, relative } from 'node:path';
22
+ import { normalizePath } from '../../shared/path-utils.js';
22
23
  /**
23
24
  * Skip-paths are matched against the path relative to `projectRoot`,
24
25
  * forward-slashed (Windows-friendly). Each entry is a SUBSTRING —
@@ -41,7 +42,7 @@ function escapeRe(input) {
41
42
  }
42
43
  /** True iff `absPath` falls under any skip-path (substring match). */
43
44
  export function shouldSkip(projectRoot, absPath) {
44
- const rel = relative(projectRoot, absPath).replace(/\\/g, '/');
45
+ const rel = normalizePath(relative(projectRoot, absPath));
45
46
  return SKIP_DIRS.some((skip) => rel.includes(skip));
46
47
  }
47
48
  /**
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { posix, relative, resolve, isAbsolute as nodeIsAbsolute } from 'node:path';
24
24
  import { isPathInsideArtifactRoot as _isPathInsideArtifactRoot } from '../../shared/path-safety.js';
25
+ import { normalizePath } from '../../shared/path-utils.js';
25
26
  export function ok(value) {
26
27
  return { ok: true, value };
27
28
  }
@@ -111,7 +112,7 @@ export function planArtifactPath(input) {
111
112
  // Always normalize separators to forward-slashes for the JSON-safe output,
112
113
  // then run posix.normalize so empty segments (foo//bar) and `..` segments
113
114
  // collapse to canonical form.
114
- const forwardSlashed = candidateRaw.replace(/\\/g, '/');
115
+ const forwardSlashed = normalizePath(candidateRaw);
115
116
  const normalized = posix.normalize(forwardSlashed);
116
117
  // Resolve against workspaceRoot. If the candidate is already absolute,
117
118
  // posix.normalize preserves the leading `/`; `resolve` keeps it absolute
@@ -129,7 +130,7 @@ export function planArtifactPath(input) {
129
130
  });
130
131
  }
131
132
  const relativePath = relative(workspaceRoot, absolutePath);
132
- const jsonSafeRelativePath = relativePath.replace(/\\/g, '/');
133
+ const jsonSafeRelativePath = normalizePath(relativePath);
133
134
  return ok({ absolutePath, relativePath, jsonSafeRelativePath });
134
135
  }
135
136
  const WORKSPACE_UNAVAILABLE_NEXT_ACTIONS = [
@@ -24,6 +24,7 @@ import { posix, relative, resolve } from 'node:path';
24
24
  import { readFile, stat } from 'node:fs/promises';
25
25
  import { isDirectory } from 'peaks-loop-shared/fs';
26
26
  import { ok, err } from './artifact-boundary.js';
27
+ import { normalizePath } from '../../shared/path-utils.js';
27
28
  // ---------------------------------------------------------------------------
28
29
  // Discovery
29
30
  // ---------------------------------------------------------------------------
@@ -63,7 +64,7 @@ export async function resolveCoverageSummaryPath(input) {
63
64
  return err({ code: 'missing', triedPaths });
64
65
  }
65
66
  function toPosix(p) {
66
- return p.replace(/\\/g, '/').replace(/\/$/, '');
67
+ return normalizePath(p).replace(/\/$/, '');
67
68
  }
68
69
  async function isReadable(p) {
69
70
  try {
@@ -112,7 +113,7 @@ export async function readC8Summary(path) {
112
113
  if (lines === null || statements === null || functions === null || branches === null) {
113
114
  continue;
114
115
  }
115
- const normalizedKey = posix.normalize(key.replace(/\\/g, '/'));
116
+ const normalizedKey = posix.normalize(normalizePath(key));
116
117
  files.set(normalizedKey, {
117
118
  path: normalizePathSep(normalizedKey),
118
119
  relativePath: normalizedKey,
@@ -141,7 +142,7 @@ function asMetric(input) {
141
142
  return { pct, covered, total };
142
143
  }
143
144
  function normalizePathSep(p) {
144
- return p.replace(/\\/g, '/');
145
+ return normalizePath(p);
145
146
  }
146
147
  // ---------------------------------------------------------------------------
147
148
  // Capability Mapping block parser (proposal.md)
@@ -215,7 +216,7 @@ function parseCapabilityRow(line) {
215
216
  return null;
216
217
  const row = {
217
218
  capability,
218
- source: source.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''),
219
+ source: normalizePath(source).replace(/^\.\//, '').replace(/\/$/, ''),
219
220
  };
220
221
  if (testAnchor !== undefined && testAnchor !== '') {
221
222
  row.testAnchor = testAnchor;
@@ -236,7 +237,7 @@ function parseCapabilityRow(line) {
236
237
  export async function resolveCapabilityFiles(input) {
237
238
  const abs = resolve(input.projectRoot, input.row.source);
238
239
  if (!(await isDirectory(abs))) {
239
- const rel = relative(input.projectRoot, abs).replace(/\\/g, '/');
240
+ const rel = normalizePath(relative(input.projectRoot, abs));
240
241
  return [rel];
241
242
  }
242
243
  return await walkTsFiles(abs, input.projectRoot);
@@ -261,7 +262,7 @@ async function walkTsFiles(absRoot, projectRoot) {
261
262
  stack.push(full);
262
263
  }
263
264
  else if (entry.isFile() && entry.name.endsWith('.ts')) {
264
- const rel = relative(projectRoot, full).replace(/\\/g, '/');
265
+ const rel = normalizePath(relative(projectRoot, full));
265
266
  out.push(rel);
266
267
  }
267
268
  }
@@ -339,7 +340,7 @@ export async function validateCapabilityCoverage(input) {
339
340
  export async function findStaleChangeFiles(input) {
340
341
  const { stat: statFn } = await import('node:fs/promises');
341
342
  const summaryMtime = (await statFn(input.summary.path)).mtimeMs;
342
- const changeRoot = `${input.openspecRoot.replace(/\\/g, '/').replace(/\/$/, '')}/changes/${input.changeId}`;
343
+ const changeRoot = `${normalizePath(input.openspecRoot).replace(/\/$/, '')}/changes/${input.changeId}`;
343
344
  const candidates = [
344
345
  `${changeRoot}/proposal.md`,
345
346
  `${changeRoot}/tasks.md`,
@@ -375,7 +376,7 @@ export async function findStaleChangeFiles(input) {
375
376
  try {
376
377
  const m = (await statFn(candidate)).mtimeMs;
377
378
  if (m > summaryMtime) {
378
- stale.push(relative(input.projectRoot, candidate).replace(/\\/g, '/'));
379
+ stale.push(normalizePath(relative(input.projectRoot, candidate)));
379
380
  }
380
381
  }
381
382
  catch {
@@ -16,6 +16,7 @@ import { dirname, join } from 'node:path';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
18
18
  import { sha256OfBody } from './handoff-service.js';
19
+ import { normalizePath } from '../../shared/path-utils.js';
19
20
  /**
20
21
  * Auto-regen the prd/handoff.md under `.peaks/_runtime/<sid>/prd/`.
21
22
  * Returns the outcome; never throws.
@@ -58,7 +59,7 @@ export async function autoRegenPrdHandoff(opts) {
58
59
  'goals: []',
59
60
  'acceptanceCriteria: []',
60
61
  'preservedBehavior: []',
61
- `handoffPath: ${handoffPath.replace(opts.projectRoot, '').replace(/\\/g, '/').replace(/^\//, '')}`,
62
+ `handoffPath: ${normalizePath(handoffPath.replace(opts.projectRoot, '')).replace(/^\//, '')}`,
62
63
  '---',
63
64
  ''
64
65
  ].join('\n');
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { extname, basename } from 'node:path';
3
+ import { normalizePath } from '../../shared/path-utils.js';
3
4
  const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.cpp', '.c', '.h', '.cs', '.rb', '.php', '.scala', '.dart', '.less', '.scss', '.sass', '.css']);
4
5
  const DOCS_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.txt']);
5
6
  const LOCKFILE_NAMES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb', 'Cargo.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'poetry.lock']);
@@ -32,7 +33,7 @@ function classifyFile(filePath) {
32
33
  * wrote `.peaks/**` markdown would be misclassified as a docs change.
33
34
  */
34
35
  function isArtifactWorkspaceFile(filePath) {
35
- const normalized = filePath.replace(/\\/g, '/');
36
+ const normalized = normalizePath(filePath);
36
37
  return normalized === '.peaks' || normalized.startsWith('.peaks/');
37
38
  }
38
39
  function tryGitDiffFiles(projectRoot, baseRef) {
@@ -18,10 +18,11 @@
18
18
  * Body of every function moved verbatim per Karpathy #3 (Surgical
19
19
  * Changes). No behavior change. The bridge adds nothing of its own.
20
20
  */
21
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
22
- import { dirname, join, resolve } from 'node:path';
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
23
  import { randomBytes } from 'node:crypto';
24
24
  import { initWorkspace } from '../workspace/workspace-service.js';
25
+ import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
25
26
  import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding } from './session-manager.js';
26
27
  // --- Lower-level helpers the bridge needs (moved verbatim) ---
27
28
  const SESSION_FILE = join('_runtime', 'session.json');
@@ -30,18 +31,6 @@ const META_FILE = 'session.json';
30
31
  function getLegacySessionFilePath(projectRoot) {
31
32
  return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
32
33
  }
33
- function canonicalizeProjectRoot(p) {
34
- try {
35
- return realpathSync(p);
36
- }
37
- catch {
38
- return resolve(p);
39
- }
40
- }
41
- function resolveStoredAgainstCaller(stored, caller) {
42
- const resolved = resolve(caller, stored);
43
- return canonicalizeProjectRoot(resolved);
44
- }
45
34
  function generateSessionId() {
46
35
  const now = new Date();
47
36
  const year = now.getFullYear();
@@ -62,7 +51,9 @@ function readSessionFile(projectRoot) {
62
51
  return null;
63
52
  try {
64
53
  const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
65
- if (data.sessionId && data.projectRoot === projectRoot) {
54
+ if (data.sessionId &&
55
+ typeof data.projectRoot === 'string' &&
56
+ projectRootsMatch(data.projectRoot, projectRoot)) {
66
57
  return data;
67
58
  }
68
59
  return null;
@@ -79,10 +70,9 @@ function readSessionFileCanonical(projectRoot) {
79
70
  return null;
80
71
  try {
81
72
  const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
82
- const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
83
73
  if (data.sessionId &&
84
- storedRaw !== null &&
85
- resolveStoredAgainstCaller(storedRaw, projectRoot) === resolveStoredAgainstCaller(projectRoot, projectRoot)) {
74
+ typeof data.projectRoot === 'string' &&
75
+ projectRootsMatch(data.projectRoot, projectRoot)) {
86
76
  return data;
87
77
  }
88
78
  return null;
@@ -97,7 +87,15 @@ function writeSessionFile(projectRoot, info) {
97
87
  if (!existsSync(dir)) {
98
88
  mkdirSync(dir, { recursive: true });
99
89
  }
100
- writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
90
+ let canonicalProjectRoot;
91
+ try {
92
+ canonicalProjectRoot = stableRealPath(info.projectRoot);
93
+ }
94
+ catch {
95
+ canonicalProjectRoot = info.projectRoot;
96
+ }
97
+ const canonicalInfo = { ...info, projectRoot: canonicalProjectRoot };
98
+ writeFileSync(sessionFile, JSON.stringify(canonicalInfo, null, 2), 'utf8');
101
99
  }
102
100
  function getMetaFilePath(projectRoot, sessionId) {
103
101
  return join(projectRoot, '.peaks', '_runtime', sessionId, META_FILE);
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkS
9
9
  import { mkdir as mkdirAsync } from 'node:fs/promises';
10
10
  import { dirname, join, resolve } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
+ import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
12
13
  import { ensureSession } from './session-binding-bridge.js';
13
14
  // As of slice 2026-06-05-peaks-runtime-layer the project-level session
14
15
  // binding lives under `.peaks/_runtime/session.json`. The legacy
@@ -89,17 +90,22 @@ function getSessionFilePath(projectRoot) {
89
90
  * Read existing session info from disk.
90
91
  * Returns null if no session file exists or if it's invalid.
91
92
  *
92
- * Strict equality on `data.projectRoot === projectRoot` is
93
- * preserved here on purpose: many other modules depend on
94
- * the strict-equality semantics to test the "no session bound"
95
- * code path. Changing the read semantics here would cascade
96
- * into many test failures — out of scope for the progress
97
- * rebind fix.
93
+ * The `projectRoot` comparison is canonicalized (separator, symlink,
94
+ * and — on Windows only — case) via `projectRootsMatch`. Before
95
+ * slice `2026-08-04-rid-001-path-canonicalize` this was a strict
96
+ * `===`, which returned null whenever the stored form differed
97
+ * cosmetically from the caller-passed form. On Windows Git Bash that
98
+ * is the common case: `peaks workspace init` stores
99
+ * `C:\Users\...\peaks-loop` while the caller passes
100
+ * `C:/Users/.../peaks-loop`, so every `getSessionId` returned null and
101
+ * `presence:set` failed closed with `PEAKS_SESSION_NOT_BOUND` —
102
+ * surfacing to the user as a permanent `peaks empty` statusline.
98
103
  *
99
- * The progress subcommands (which are the surface that
100
- * actually breaks on the rebind bug) use
101
- * `getSessionIdCanonical` instead, which does the
102
- * canonicalize-on-read resolution the bug fix needs.
104
+ * Canonicalization is a strict widening: paths that denote the same
105
+ * physical directory now match, and paths that denote different
106
+ * directories still do not (pinned by the Case 4 regression test in
107
+ * `tests/unit/session/session-manager-path-canonicalize.test.ts`), so
108
+ * the "no session bound" code path other modules depend on is intact.
103
109
  */
104
110
  function readSessionFile(projectRoot) {
105
111
  const sessionFile = getSessionFilePath(projectRoot);
@@ -112,7 +118,7 @@ function readSessionFile(projectRoot) {
112
118
  return null;
113
119
  try {
114
120
  const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
115
- if (data.sessionId && data.projectRoot === projectRoot) {
121
+ if (data.sessionId && typeof data.projectRoot === 'string' && projectRootsMatch(data.projectRoot, projectRoot)) {
116
122
  return data;
117
123
  }
118
124
  return null;
@@ -157,6 +163,13 @@ function readSessionFileCanonical(projectRoot) {
157
163
  * `.peaks/_runtime/session.json`. The `.peaks/_runtime/` directory is
158
164
  * created on demand. The legacy `.peaks/.session.json` is NOT written by
159
165
  * this slice; it is only read for back-compat.
166
+ *
167
+ * The persisted `projectRoot` is passed through `stableRealPath` so the
168
+ * stored form is symlink-resolved and stable across callers. We store
169
+ * the REAL path, not the `projectRootCompareKey` — the key is
170
+ * lossy (lower-cased on Windows) and is only ever a comparison
171
+ * artifact. Reads tolerate either form via `projectRootsMatch`, so
172
+ * bindings written by older versions keep resolving.
160
173
  */
161
174
  function writeSessionFile(projectRoot, info) {
162
175
  const sessionFile = getSessionFilePath(projectRoot);
@@ -164,7 +177,18 @@ function writeSessionFile(projectRoot, info) {
164
177
  if (!existsSync(dir)) {
165
178
  mkdirSync(dir, { recursive: true });
166
179
  }
167
- writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
180
+ let canonicalProjectRoot;
181
+ try {
182
+ canonicalProjectRoot = stableRealPath(info.projectRoot);
183
+ }
184
+ catch {
185
+ // Never block a write on canonicalization: if the path cannot be
186
+ // realpath'd, persist the caller's form unchanged. Reads canonicalize
187
+ // both sides anyway, so a non-canonical stored value still matches.
188
+ canonicalProjectRoot = info.projectRoot;
189
+ }
190
+ const canonicalInfo = { ...info, projectRoot: canonicalProjectRoot };
191
+ writeFileSync(sessionFile, JSON.stringify(canonicalInfo, null, 2), 'utf8');
168
192
  }
169
193
  /**
170
194
  * Drop the project-level session binding at the canonical
@@ -137,26 +137,7 @@ function buildPalette(capability, noColor) {
137
137
  }
138
138
  const BREATHING_GLYPHS_UNICODE = ['●', '◐', '◑', '◒', '◓'];
139
139
  const BREATHING_GLYPHS_ASCII = ['*', 'o', '+', '~', '|'];
140
- const BREATHING_PERIOD_MS = 2_400;
141
- /**
142
- * Bee-tier (1-level sub-role) skills and their full orchestrator
143
- * parent name. The terminal status line shows both layers when a bee
144
- * is active: the active bee name plus a `↑<parent-full>` marker
145
- * pointing at the dispatching orchestrator.
146
- */
147
- const BEE_TO_PARENT = {
148
- 'peaks-prd': 'peaks-code',
149
- 'peaks-rd': 'peaks-code',
150
- 'peaks-qa': 'peaks-code',
151
- 'peaks-ui': 'peaks-code',
152
- 'peaks-sc': 'peaks-code',
153
- 'peaks-txt': 'peaks-code',
154
- 'peaks-final-review': 'peaks-code',
155
- 'peaks-resume': 'peaks-code',
156
- 'peaks-status': 'peaks-code',
157
- 'peaks-test': 'peaks-code',
158
- 'peaks-reviewer': 'peaks-code',
159
- };
140
+ const BREATHING_PERIOD_MS = 600;
160
141
  function pickBreathingGlyph(capability, nowMs) {
161
142
  const set = capability === 'ascii' ? BREATHING_GLYPHS_ASCII : BREATHING_GLYPHS_UNICODE;
162
143
  const index = Math.floor((nowMs % BREATHING_PERIOD_MS) / (BREATHING_PERIOD_MS / set.length)) % set.length;
@@ -210,8 +191,23 @@ function rootLabel(projectRoot) {
210
191
  * prefix and the project root label. Kept separate so the token layout
211
192
  * is obvious at the call site and so each state has a single
212
193
  * responsibility.
194
+ *
195
+ * Active-leaf rendering (slice 2026-08-04-rid-005-statusline-dual-skill):
196
+ * when the model carries an `activeLeaf` (an in-flight bee dispatch under
197
+ * the orchestrator), the line surfaces the leaf role alongside the
198
+ * orchestrator skill. The render priorities are:
199
+ *
200
+ * - activeLeaf === null → `${skill}` (current behavior)
201
+ * - activeLeaf.pendingCount === 1 → `${leaf} | ${skill}`
202
+ * - activeLeaf.pendingCount > 1 → `${leaf} (+${N-1}) | ${skill}`
203
+ *
204
+ * The orchestrator skill itself is rendered with its mode token; the leaf
205
+ * role is rendered without a mode (the leaf does not own the mode state —
206
+ * the orchestrator does). The 14→1 bee skill mapping that previously
207
+ * forced every bee role to render with a `↑<parent>` marker was removed
208
+ * in this slice; the dual-skill layout above replaces it.
213
209
  */
214
- function renderActive(presence, palette, nowMs, capability, noColor) {
210
+ function renderActive(presence, palette, nowMs, capability, noColor, activeLeaf) {
215
211
  if (!presence) {
216
212
  return `${palette.idle} ${palette.idleLabel}`;
217
213
  }
@@ -222,16 +218,17 @@ function renderActive(presence, palette, nowMs, capability, noColor) {
222
218
  }
223
219
  const skill = presence.skill;
224
220
  const dot = renderActiveDot(capability, nowMs, noColor);
225
- const beeParent = BEE_TO_PARENT[skill];
226
221
  const modeToken = typeof presence.mode === 'string' && presence.mode.length > 0
227
222
  ? brandRun(` [${presence.mode}]`, noColor, capability)
228
223
  : '';
229
- // Bee-tier skills surface both layers: the active bee name plus
230
- // the full parent orchestrator name. Mode is shown for every
231
- // active skill (not just peaks-code) so the line carries the same
232
- // mode taxonomy for any bee in flight.
233
- if (beeParent !== undefined) {
234
- return `${dot} ${brandRun(skill, noColor, capability)} ${brandRun(`↑${beeParent}`, noColor, capability)}${modeToken}`;
224
+ // Dual-skill layout: leaf role (in-flight bee) + orchestrator skill.
225
+ if (activeLeaf !== null) {
226
+ const leaf = brandRun(activeLeaf.role, noColor, capability);
227
+ const tail = activeLeaf.pendingCount > 1
228
+ ? ` ${brandRun(`(+${activeLeaf.pendingCount - 1})`, noColor, capability)}`
229
+ : '';
230
+ const sep = brandRun(' | ', noColor, capability);
231
+ return `${dot} ${leaf}${tail}${sep}${brandRun(skill, noColor, capability)}${modeToken}`;
235
232
  }
236
233
  return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}`;
237
234
  }
@@ -401,7 +398,7 @@ export function isNoColorEnv(env) {
401
398
  }
402
399
  /**
403
400
  * Marquee scan band — a single-pass light band that sweeps left ↔ right
404
- * across the entire status line on a 2 s round trip. The band's
401
+ * across the entire status line on a 0.4 s round trip. The band's
405
402
  * foreground color is `#E0E0E0` (off-white) with `1;` (bold) — see
406
403
  * {@link HIGHLIGHT_SGR_OPEN}. Cells OUTSIDE the band keep their
407
404
  * original SGR (brand purple or semantic warning/failed); only cells
@@ -430,8 +427,8 @@ export function isNoColorEnv(env) {
430
427
  *
431
428
  * ASCII tier: skipped — there are no SGR codes to inject.
432
429
  */
433
- const MARQUEE_PERIOD_MS = 2_000;
434
- const MARQUEE_BAND_WIDTH = 5;
430
+ const MARQUEE_PERIOD_MS = 400;
431
+ const MARQUEE_BAND_WIDTH = 2;
435
432
  /**
436
433
  * Visible-character width of an ANSI-bearing string. Skips every
437
434
  * `\x1b[...m` escape so the count reflects what the terminal paints,
@@ -590,7 +587,7 @@ export function renderStatusLine(model, options, env) {
590
587
  else {
591
588
  switch (model.state) {
592
589
  case 'active':
593
- line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor)}${rootSuffix}`;
590
+ line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor, model.activeLeaf)}${rootSuffix}`;
594
591
  break;
595
592
  case 'stale':
596
593
  line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
@@ -6,6 +6,7 @@ export type StatusLineStdin = {
6
6
  };
7
7
  cwd?: string;
8
8
  session_id?: string;
9
+ caller_id?: string;
9
10
  };
10
11
  export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
11
12
  export type StatusLinePresence = {
@@ -15,12 +16,17 @@ export type StatusLinePresence = {
15
16
  setAt?: string;
16
17
  claudeSessionId?: string;
17
18
  };
19
+ export type StatusLineActiveLeaf = {
20
+ role: string;
21
+ pendingCount: number;
22
+ };
18
23
  export type StatusLineModel = {
19
24
  state: StatusLineState;
20
25
  projectRoot: string | null;
21
26
  presence: StatusLinePresence | null;
22
27
  ageMs: number | null;
23
28
  compact: CompactStatuslineState;
29
+ activeLeaf: StatusLineActiveLeaf | null;
24
30
  };
25
31
  export declare function parseStatusLineStdin(raw: string): StatusLineStdin | null;
26
32
  export declare function buildStatusLineModel(stdin: StatusLineStdin | null, nowMs: number): StatusLineModel;
@@ -3,6 +3,8 @@ import { resolve } from 'node:path';
3
3
  import { findProjectRoot } from '../config/config-safety.js';
4
4
  import { decideCompactStatusline } from '../compact-statusline/compact-statusline-service.js';
5
5
  import { getSessionIdCanonical } from '../session/session-manager.js';
6
+ import { resolveActiveSkillForCaller } from '../audit/enforcers/active-skill-resolver.js';
7
+ import { readActiveDispatchIndex } from '../dispatch/dispatch-record-writer.js';
6
8
  /**
7
9
  * Out-of-band Peaks skill status renderer for the Claude Code statusLine.
8
10
  *
@@ -47,11 +49,93 @@ export function parseStatusLineStdin(raw) {
47
49
  return null;
48
50
  }
49
51
  }
52
+ /**
53
+ * Resolve the callerId for the read-side isolation. Order of resolution:
54
+ * 1. `stdin?.caller_id` (when the harness / IDE adapter forwards it)
55
+ * 2. `process.env.CLAUDE_CODE_SESSION_ID` (Claude Code's ambient session id;
56
+ * used as a coarse callerId surrogate when stdin omits caller_id)
57
+ * 3. `null` (no callerId — caller falls back to the project-level
58
+ * single-file read for back-compat)
59
+ */
60
+ function resolveCallerId(stdin) {
61
+ const fromStdin = typeof stdin?.caller_id === 'string' && stdin.caller_id.length > 0
62
+ ? stdin.caller_id
63
+ : null;
64
+ if (fromStdin !== null)
65
+ return fromStdin;
66
+ const fromEnv = process.env['CLAUDE_CODE_SESSION_ID'];
67
+ if (typeof fromEnv === 'string' && fromEnv.length > 0)
68
+ return fromEnv;
69
+ return null;
70
+ }
71
+ /**
72
+ * Read the active-dispatch index for the canonical session, filter to
73
+ * in-flight entries (status NOT IN { done, failed, cancelled, no-execution,
74
+ * never-started, unreadable, stale }), and return the most-recent leaf role
75
+ * plus the total in-flight count. Returns `{ role: null, pendingCount: 0 }`
76
+ * when no session id resolves, the index is empty, or every entry is terminal.
77
+ *
78
+ * READ-ONLY: only reads `.peaks/_sub_agents/<sid>/active-dispatches.json`.
79
+ * Never mutates the on-disk record.
80
+ */
81
+ function readActiveLeaf(projectRoot, sessionId) {
82
+ if (sessionId === null)
83
+ return null;
84
+ let index = {};
85
+ try {
86
+ index = readActiveDispatchIndex(projectRoot, sessionId);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ const terminalStatuses = new Set([
92
+ 'done',
93
+ 'failed',
94
+ 'cancelled',
95
+ 'no-execution',
96
+ 'never-started',
97
+ 'unreadable',
98
+ 'stale',
99
+ ]);
100
+ const inFlight = Object.values(index).filter((e) => !terminalStatuses.has(e.status));
101
+ if (inFlight.length === 0)
102
+ return null;
103
+ // Sort by createdAt descending — the most recently dispatched leaf wins.
104
+ const sorted = inFlight.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
105
+ const latest = sorted[0];
106
+ if (latest === undefined)
107
+ return null;
108
+ return { role: latest.role, pendingCount: inFlight.length };
109
+ }
50
110
  /**
51
111
  * Read the presence file without any side effects. Returns null when the file is
52
112
  * absent (idle) and a sentinel object for malformed content (invalid-presence).
113
+ *
114
+ * When `callerId` is non-null, delegates to the canonical lease resolver
115
+ * (presence-lease-service via active-skill-resolver) so the read is
116
+ * session+caller-isolated. When `callerId` is null, falls back to the
117
+ * project-level single-file read (back-compat for callers that don't pass
118
+ * a callerId yet, e.g. legacy CLI invocations).
53
119
  */
54
- function readPresenceReadOnly(projectRoot) {
120
+ function readPresenceReadOnly(projectRoot, callerId) {
121
+ if (callerId !== null) {
122
+ try {
123
+ const resolution = resolveActiveSkillForCaller(projectRoot, { legacyPresence: true, callerId });
124
+ if (resolution.source === 'none' || resolution.skill === null) {
125
+ return { presence: null, invalid: false };
126
+ }
127
+ return {
128
+ presence: {
129
+ skill: resolution.skill,
130
+ ...(resolution.mode !== null ? { mode: resolution.mode } : {}),
131
+ },
132
+ invalid: false,
133
+ };
134
+ }
135
+ catch {
136
+ return { presence: null, invalid: true };
137
+ }
138
+ }
55
139
  const presencePath = resolve(projectRoot, PRESENCE_FILE);
56
140
  // Back-compat: prefer the new canonical path; fall back to the legacy
57
141
  // `.peaks/.active-skill.json` for one minor release.
@@ -91,14 +175,16 @@ export function buildStatusLineModel(stdin, nowMs) {
91
175
  // fallbacks). It replaces the active skill content when kind != 'none'.
92
176
  const compact = readCompactState(projectRoot, nowMs);
93
177
  if (projectRoot === null) {
94
- return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact };
178
+ return { state: 'idle', projectRoot: null, presence: null, ageMs: null, compact, activeLeaf: null };
95
179
  }
96
- const { presence, invalid } = readPresenceReadOnly(projectRoot);
180
+ // callerId resolves the read-side isolation; back-compat is `null`.
181
+ const callerId = resolveCallerId(stdin);
182
+ const { presence, invalid } = readPresenceReadOnly(projectRoot, callerId);
97
183
  if (invalid) {
98
- return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact };
184
+ return { state: 'invalid-presence', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
99
185
  }
100
186
  if (presence === null) {
101
- return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
187
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
102
188
  }
103
189
  // Session binding: when the presence was stamped with a Claude session id and
104
190
  // the live session (from stdin) is a different one, the recorded skill belongs
@@ -107,12 +193,24 @@ export function buildStatusLineModel(stdin, nowMs) {
107
193
  // fall back to the time-based behavior below for backward compatibility.
108
194
  const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
109
195
  if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
110
- return { state: 'idle', projectRoot, presence: null, ageMs: null, compact };
196
+ return { state: 'idle', projectRoot, presence: null, ageMs: null, compact, activeLeaf: null };
111
197
  }
112
198
  const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
113
199
  const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
114
200
  const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
115
- return { state, projectRoot, presence, ageMs, compact };
201
+ // Active leaf resolution: read-only query against the per-session
202
+ // active-dispatches index. Filter to in-flight entries; pick the most
203
+ // recent by createdAt. The renderer uses this to surface the in-flight
204
+ // bee skill (e.g. `peaks-rd`) alongside the orchestrator (e.g. `peaks-code`).
205
+ let activeLeaf = null;
206
+ try {
207
+ const sessionId = getSessionIdCanonical(projectRoot);
208
+ activeLeaf = readActiveLeaf(projectRoot, sessionId);
209
+ }
210
+ catch {
211
+ activeLeaf = null;
212
+ }
213
+ return { state, projectRoot, presence, ageMs, compact, activeLeaf };
116
214
  }
117
215
  /**
118
216
  * Read-only compact state resolver. Resolves the canonical session id for the
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { randomBytes } from 'node:crypto';
22
22
  import { posix as path } from 'node:path';
23
+ import { normalizePath } from '../../shared/path-utils.js';
23
24
  export const VM_HYPERVISORS = Object.freeze([
24
25
  'kvm', 'hyperkit', 'hyperv'
25
26
  ]);
@@ -128,7 +129,7 @@ export function deserializeVmLease(raw) {
128
129
  function joinPath(...segments) {
129
130
  if (segments.length === 0)
130
131
  return '';
131
- const normalized = segments.map((s) => s.replace(/\\/g, '/'));
132
+ const normalized = segments.map((s) => normalizePath(s));
132
133
  let acc = normalized[0];
133
134
  for (let i = 1; i < normalized.length; i++) {
134
135
  acc = path.join(acc, normalized[i]);
@@ -20,6 +20,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
20
20
  // `.peaks/_runtime/<sessionId>/<role>/...` string is no longer
21
21
  // pre-built.
22
22
  import { getSessionDir } from '../session/getSessionDir.js';
23
+ import { normalizePath } from '../../shared/path-utils.js';
23
24
  const MAX_RESUME_ARTIFACT_BYTES = 256_000;
24
25
  // Slice 2026-06-29-change-id-root-removal: return the bare role-relative
25
26
  // sub-paths instead of `.peaks/_runtime/change/<id>/<role>/...` strings.
@@ -65,7 +66,7 @@ function normalizeRoleRelativePath(artifact, _sessionId) {
65
66
  // the path separators and strips any leading `/`; the `sessionId`
66
67
  // argument is preserved on the signature for backward call-site
67
68
  // compatibility but is no longer embedded in the path.
68
- return artifact.replace(/\\/g, '/').replace(/^\/+/, '');
69
+ return normalizePath(artifact).replace(/^\/+/, '');
69
70
  }
70
71
  function readResumeArtifact(artifactWorkspacePath, sessionId, artifact) {
71
72
  // Slice 2026-06-29-change-id-root-removal: on-disk home now lives under
@@ -73,7 +74,7 @@ function readResumeArtifact(artifactWorkspacePath, sessionId, artifact) {
73
74
  // sub-path (e.g. `rd/swarm/checkpoints/checkpoint-1.json`) is
74
75
  // strictly a sub-root drill, not a top-level dir derivation.
75
76
  const sessionScopeRoot = getSessionDir(artifactWorkspacePath, sessionId);
76
- const normalizedArtifact = artifact.replace(/\\/g, '/').replace(/^\/+/, '');
77
+ const normalizedArtifact = normalizePath(artifact).replace(/^\/+/, '');
77
78
  const artifactPath = resolve(sessionScopeRoot, normalizedArtifact);
78
79
  try {
79
80
  const artifactWorkspaceRealPath = realpathSync(artifactWorkspacePath);
@@ -3,6 +3,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { isDirectory } from 'peaks-loop-shared/fs';
5
5
  import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
6
+ import { normalizePath } from '../../shared/path-utils.js';
6
7
  /**
7
8
  * Slice 2026-06-29-change-id-root-removal: list the immediate children of
8
9
  * `.peaks/` so the legacy sibling-dir guard can enumerate date-stamped
@@ -418,7 +419,7 @@ export function isWriterCreatedSiblingShape(siblingDir) {
418
419
  continue;
419
420
  }
420
421
  if (stat.isFile()) {
421
- const rel = node.rel.replace(/\\/g, '/');
422
+ const rel = normalizePath(node.rel);
422
423
  const matches = WRITER_ALLOWED_RELATIVE_PATTERNS.some((re) => re.test(rel));
423
424
  if (!matches) {
424
425
  return false;
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { randomBytes } from 'node:crypto';
28
28
  import { posix as path } from 'node:path';
29
+ import { normalizePath } from '../../shared/path-utils.js';
29
30
  /**
30
31
  * Per-role default TTL. Sub-agent dispatch duration varies by role:
31
32
  * - rd (long planning + impl) → 30 min
@@ -233,7 +234,7 @@ export function deserializeLease(raw) {
233
234
  function joinPath(...segments) {
234
235
  if (segments.length === 0)
235
236
  return '';
236
- const normalized = segments.map((s) => s.replace(/\\/g, '/'));
237
+ const normalized = segments.map((s) => normalizePath(s));
237
238
  let acc = normalized[0];
238
239
  for (let i = 1; i < normalized.length; i++) {
239
240
  acc = path.join(acc, normalized[i]);
@@ -10,11 +10,9 @@
10
10
  * check without bringing in the change-id axis.
11
11
  */
12
12
  import { posix } from 'node:path';
13
- function normalizeForwardSlashes(input) {
14
- return input.replace(/\\/g, '/');
15
- }
13
+ import { normalizePath } from './path-utils.js';
16
14
  function hasUnsafePathShape(input) {
17
- const normalized = normalizeForwardSlashes(input);
15
+ const normalized = normalizePath(input);
18
16
  if (input.includes('\\'))
19
17
  return true;
20
18
  if (!normalized || normalized === '.' || normalized === '..')
@@ -36,7 +34,7 @@ export function isUnsafeArtifactPath(path) {
36
34
  return isUnsafePathInput(path);
37
35
  }
38
36
  function normalizeArtifactPath(input) {
39
- const normalized = posix.normalize(normalizeForwardSlashes(input));
37
+ const normalized = posix.normalize(normalizePath(input));
40
38
  return normalized.replace(/\/$/, '');
41
39
  }
42
40
  export { normalizeArtifactPath as _normalizeArtifactPath };
@@ -11,3 +11,51 @@ export declare function isWindowsAbsolutePath(path: string): boolean;
11
11
  export declare function resolveInputPath(path: string): string;
12
12
  export declare function stableRealPath(path: string): string;
13
13
  export declare function stablePath(path: string): string;
14
+ /**
15
+ * Build the comparison key for a `projectRoot` value. Two paths that
16
+ * denote the same physical directory MUST produce the same key, and two
17
+ * paths that denote different directories MUST NOT.
18
+ *
19
+ * Composed from the existing `path-utils.ts` primitives; no new
20
+ * dependency. Three normalizations, in order:
21
+ *
22
+ * 1. **Symlink resolution** via `stableRealPath` — collapses macOS
23
+ * `/var/folders/...` vs `/private/var/folders/...` (`/var` is a
24
+ * symlink to `/private/var`). Falls back to `resolveInputPath`
25
+ * when the path does not exist, so this never throws for callers
26
+ * that pass a not-yet-created root (or a stale binding whose
27
+ * directory has since been deleted).
28
+ * 2. **Separator folding** via `normalizePath` — collapses the
29
+ * Windows `C:\Users\...` (written by `peaks workspace init`) vs
30
+ * `C:/Users/...` (passed by a Git Bash caller) split that was the
31
+ * original `PEAKS_SESSION_NOT_BOUND` trigger.
32
+ * 3. **Case folding, Windows only** — NTFS is case-insensitive, so
33
+ * `C:\Foo` and `c:/foo` are the same directory. This step is
34
+ * guarded on `isWindows` because POSIX filesystems are
35
+ * case-SENSITIVE: folding case on Linux would make the genuinely
36
+ * distinct `/tmp/Foo` and `/tmp/foo` compare equal.
37
+ *
38
+ * Note on step 3: `realpathSync` alone is NOT sufficient for the
39
+ * Windows case split. Verified empirically on Node 22 / win32 —
40
+ * `realpathSync('c:\\...\\foo')` echoes the caller's casing, while only
41
+ * `realpathSync.native` case-folds. We do not switch to `.native`
42
+ * because it also rewrites 8.3 short names (`SMALLM~1` → `smallMark`),
43
+ * which would change the on-disk form of every existing binding.
44
+ * Explicit `toLowerCase()` is the narrower, more predictable fix.
45
+ *
46
+ * This is a compare key ONLY — never persist it. The value written to
47
+ * disk stays the real path (see `writeSessionFile`).
48
+ *
49
+ * Lifted from `src/services/session/session-manager.ts` in slice
50
+ * `2026-08-04-rid-002-bridge-canonicalize` so both `session-manager.ts`
51
+ * and `session-binding-bridge.ts` share a single canonicalization
52
+ * authority.
53
+ */
54
+ export declare function projectRootCompareKey(p: string): string;
55
+ /**
56
+ * Compare two `projectRoot` values under the canonicalization rules
57
+ * documented on `projectRootCompareKey`. Returns true iff both paths
58
+ * canonicalize to the same key — covers symlink resolution, separator
59
+ * folding, and (Windows only) case folding.
60
+ */
61
+ export declare function projectRootsMatch(a: string, b: string): boolean;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
2
  import { isAbsolute, parse, relative, resolve, sep } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
- import { platform } from './platform.js';
4
+ import { isWindows, platform } from './platform.js';
5
5
  export const SEP = sep;
6
6
  const localPathConverters = {
7
7
  win32: (p) => p.replace(/\//g, '\\'),
@@ -54,3 +54,67 @@ export function stablePath(path) {
54
54
  const realExistingPath = existsSync(currentPath) ? stableRealPath(currentPath) : parsedPath.root;
55
55
  return resolve(realExistingPath, ...missingSegments);
56
56
  }
57
+ /**
58
+ * Build the comparison key for a `projectRoot` value. Two paths that
59
+ * denote the same physical directory MUST produce the same key, and two
60
+ * paths that denote different directories MUST NOT.
61
+ *
62
+ * Composed from the existing `path-utils.ts` primitives; no new
63
+ * dependency. Three normalizations, in order:
64
+ *
65
+ * 1. **Symlink resolution** via `stableRealPath` — collapses macOS
66
+ * `/var/folders/...` vs `/private/var/folders/...` (`/var` is a
67
+ * symlink to `/private/var`). Falls back to `resolveInputPath`
68
+ * when the path does not exist, so this never throws for callers
69
+ * that pass a not-yet-created root (or a stale binding whose
70
+ * directory has since been deleted).
71
+ * 2. **Separator folding** via `normalizePath` — collapses the
72
+ * Windows `C:\Users\...` (written by `peaks workspace init`) vs
73
+ * `C:/Users/...` (passed by a Git Bash caller) split that was the
74
+ * original `PEAKS_SESSION_NOT_BOUND` trigger.
75
+ * 3. **Case folding, Windows only** — NTFS is case-insensitive, so
76
+ * `C:\Foo` and `c:/foo` are the same directory. This step is
77
+ * guarded on `isWindows` because POSIX filesystems are
78
+ * case-SENSITIVE: folding case on Linux would make the genuinely
79
+ * distinct `/tmp/Foo` and `/tmp/foo` compare equal.
80
+ *
81
+ * Note on step 3: `realpathSync` alone is NOT sufficient for the
82
+ * Windows case split. Verified empirically on Node 22 / win32 —
83
+ * `realpathSync('c:\\...\\foo')` echoes the caller's casing, while only
84
+ * `realpathSync.native` case-folds. We do not switch to `.native`
85
+ * because it also rewrites 8.3 short names (`SMALLM~1` → `smallMark`),
86
+ * which would change the on-disk form of every existing binding.
87
+ * Explicit `toLowerCase()` is the narrower, more predictable fix.
88
+ *
89
+ * This is a compare key ONLY — never persist it. The value written to
90
+ * disk stays the real path (see `writeSessionFile`).
91
+ *
92
+ * Lifted from `src/services/session/session-manager.ts` in slice
93
+ * `2026-08-04-rid-002-bridge-canonicalize` so both `session-manager.ts`
94
+ * and `session-binding-bridge.ts` share a single canonicalization
95
+ * authority.
96
+ */
97
+ export function projectRootCompareKey(p) {
98
+ let canonical;
99
+ try {
100
+ canonical = stableRealPath(p);
101
+ }
102
+ catch {
103
+ // Path does not exist (yet). Fall back to the resolved absolute
104
+ // form so a binding written before its directory exists — and a
105
+ // binding whose directory was later deleted — still compares by
106
+ // separator + case rather than throwing.
107
+ canonical = resolveInputPath(p);
108
+ }
109
+ const separatorFolded = normalizePath(canonical);
110
+ return isWindows ? separatorFolded.toLowerCase() : separatorFolded;
111
+ }
112
+ /**
113
+ * Compare two `projectRoot` values under the canonicalization rules
114
+ * documented on `projectRootCompareKey`. Returns true iff both paths
115
+ * canonicalize to the same key — covers symlink resolution, separator
116
+ * folding, and (Windows only) case folding.
117
+ */
118
+ export function projectRootsMatch(a, b) {
119
+ return projectRootCompareKey(a) === projectRootCompareKey(b);
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.9",
3
+ "version": "4.0.10",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -98,9 +98,9 @@
98
98
  "headroom-ai": "0.22.4",
99
99
  "yaml": "^2.9.0",
100
100
  "zod": "^3.25.76",
101
- "peaks-loop-mut": "0.1.15",
102
- "peaks-loop-shared": "0.0.40",
103
- "peaks-loop-shared-channel": "0.0.19"
101
+ "peaks-loop-shared": "0.0.41",
102
+ "peaks-loop-shared-channel": "0.0.19",
103
+ "peaks-loop-mut": "0.1.15"
104
104
  },
105
105
  "peerDependencies": {
106
106
  "@alibaba-group/open-code-review": "1.3.1"