peaks-loop 4.0.14 → 4.0.15

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.15 — 2026-08-06 (rotation guards tightening + caller-derived workflowId + caller-binding primary source + atomic write hygiene)
4
+
5
+ **Tightens rotation guards with 4th same-process re-resolve short-circuit** (slice `2026-08-06-rotation-guards-and-caller-binding`, commit `f38a796f`):
6
+ - `src/services/session/session-binding-bridge.ts` introduces a module-scoped `lastResolvedOuter: { value: string | undefined; resolvedAt: number } | null` populated on every non-throw `getCurrentOuterSessionId` call (env hit / cache hit / undefined fallback). `ensureSessionWithRotation` now short-circuits on a 4th additive guard: when `lastResolvedOuter.value === currentOuterSessionId === boundOuter`, the rotation path is skipped. The 3 legacy guards (currentOuterSessionId === undefined / boundOuter empty / boundOuter === currentOuter) are preserved verbatim per PRD PB3. Test seam `_resetLastResolvedOuterForTest()` exported.
7
+ - Tests: `tests/unit/session/rotation-guards-tightening.test.ts` (11 cases: 3 legacy-guard regressions + 4th-guard flip A→B same-process + per-process isolation + writeAtomic fallback + skipRotate + module-state integrity).
8
+
9
+ **Caller-derived workflowId in legacy compat shim** (slice same, commit `2f6322a3`):
10
+ - `src/services/skills/skill-presence-service.ts:391` switches the legacy compat shim's `workflowId` from `wf-${sessionId}-compat` to `wf-${projection.callerId.slice(0, 189)}-compat`. The slice(0, 189) protects against `WORKFLOW_ID_REGEX` overflow (3 + 200 callerId + 7 = 210 chars > 200 regex cap). `presence-lease-service.ts` and the sub-agent graph workflowId generators at `workflow-presence-lifecycle.ts:95` + `workflow-lifecycle-commands.ts:125` are untouched (per-invocation `wf-${Date.now().toString(36)}`, NOT legacy compat shims).
11
+ - No migration of legacy `wf-<sid>-compat` leases from 4.0.14 installs; they become orphans and are reaped by the 24h30m stale-started GC at `presence-lease-service.ts:42`.
12
+ - Tests: `tests/unit/services/skills/workflow-id-caller-derivation.test.ts` (8 cases: short / long / 200-char callerId boundary / graphRef D4a validation / 2-caller key isolation / legacy back-compat read / slice loss-free regression).
13
+
14
+ **Caller-binding becomes primary binding source + atomic write hygiene** (slice same, commit `97caa66b`):
15
+ - `src/services/session/session-binding-bridge.ts:184-310` `ensureSession` now uses a 3-tier read order: **`callers/<callerId>.json` (if `resolveCallerProjection` succeeds)** → `session.json` (fallback) → fresh-generate. Write path: BOTH files written on first bind or rotation, with `callers/<callerId>.json` FIRST (source of truth) + `session.json` SECOND (denormalized cache for legacy consumers). The lazy `resolveCallerProjection` import is wrapped in try/catch so `PEAKS_CALLER_NOT_RESOLVED` falls through to `session.json` (preserves legacy behavior).
16
+ - `src/services/session/session-manager.ts` adds internal helper `getSessionIdFromCallerBinding` (NOT exported; called from `getSessionId` and `getSessionIdCanonical` BEFORE `readSessionFile` / `readSessionFileCanonical`). Public signatures unchanged — all 14 `getSessionId(` consumers continue to compile and run without modification.
17
+ - `src/services/session/caller-binding-service.ts:113-133` `setCallerBinding` swaps non-atomic `writeFileSync` for `atomicWriteJson` (single-line, plus import + removal of the now-redundant `mkdirSync` block — atomicWriteJson does its own `mkdirSync(dir, { recursive: true })`).
18
+ - `src/cli/commands/outer-cache-commands.ts:125` `peaks outer-cache write` swaps non-atomic `writeFileSync` for `atomicWriteJson` (carry-forward bug from 4.0.14 QA issue #1: a power-loss mid-write left the cache in "permanent bad state" until the next SessionStart).
19
+ - Tests: `tests/unit/session/caller-binding-primary-source.test.ts` (11 cases: 3-tier read order / dual-write ordering / callerId-unresolved fallback / malformed file graceful fallback / `getSessionId` preservation / `skipRotate` honored / 14-consumer regression / `setCallerBinding` atomic / temp-file cleanup) + `tests/unit/cli/outer-cache-atomic-write.test.ts` (4 cases: writeFileSync residue check / atomicWriteJson call / 4.0.14 AC1-AC7 regression / simulated write failure).
20
+ - All 5 modified files stay under the 800 LOC cap: `session-binding-bridge.ts` 527 / `skill-presence-service.ts` 695 / `session-manager.ts` 618 / `caller-binding-service.ts` 204 / `outer-cache-commands.ts` 201. Regression sweep: 229/230 PASS / 1 SKIP (pre-existing Win-only conditional). `tsc -p tsconfig.json --noEmit` clean for slice files.
21
+
22
+ **Lockstep bump.** peaks-loop-shared `0.0.44 → 0.0.45` (CLI_VERSION re-stamped to 4.0.15); peaks-loop-mut `0.1.17 → 0.1.18`; peaks-loop-shared-channel `0.0.21 → 0.0.22`.
23
+
3
24
  ## 4.0.14 — 2026-08-06 (outer-session cache + ensureSession meta over-coverage)
4
25
 
5
26
  **Fixes the "5 terminals / 5 sessions stuck on 3fe1be" bug** (slice `2026-08-06-session-outer-cache-and-meta-coverage`, commit `f02a9b45`):
@@ -25,9 +25,10 @@
25
25
  * Both subcommands accept `--project <path>` (defaults to cwd / git
26
26
  * root) and `--json` (default in TTY-less invocations).
27
27
  */
28
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
- import { dirname, join } from 'node:path';
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
30
  import { fail, ok } from 'peaks-loop-shared/result';
31
+ import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
31
32
  import { addJsonOption, printResult } from '../cli-helpers.js';
32
33
  import { findProjectRoot } from '../../services/config/config-safety.js';
33
34
  const OUTER_SESSION_CACHE_REL = join('.peaks', '_runtime', '.outer-session-cache.json');
@@ -88,10 +89,16 @@ export function registerOuterCacheCommands(program, io) {
88
89
  const capturedAt = new Date().toISOString();
89
90
  const payload = { outerSessionId, capturedAt };
90
91
  try {
91
- const dir = dirname(cachePath);
92
- if (!existsSync(dir))
93
- mkdirSync(dir, { recursive: true });
94
- writeFileSync(cachePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
92
+ // Slice 2026-08-06-session-cacde8-A.5c: atomic write (temp +
93
+ // rename) so a power-loss mid-write cannot leave the cache file
94
+ // truncated. `atomicWriteJson` owns its own
95
+ // `mkdirSync(dir, { recursive: true })` so the inline mkdir
96
+ // block is removed. The previous `writeFileSync` path was the
97
+ // 4.0.14 carry-forward QA issue #1; the bridge's cache-miss
98
+ // fallback treated the truncated state as a miss (safe) but
99
+ // the file was stuck in "permanent bad state" until the next
100
+ // SessionStart fired.
101
+ atomicWriteJson(cachePath, payload);
95
102
  }
96
103
  catch (error) {
97
104
  const message = error instanceof Error ? error.message : String(error);
@@ -24,14 +24,14 @@ export declare const InstanceRecordSchema: z.ZodObject<{
24
24
  callerId: z.ZodString;
25
25
  lastHeartbeat: z.ZodString;
26
26
  }, "strip", z.ZodTypeAny, {
27
+ callerId: string;
27
28
  lastHeartbeat: string;
28
29
  startedAt: string;
29
30
  roles: string[];
30
- callerId: string;
31
31
  }, {
32
+ callerId: string;
32
33
  lastHeartbeat: string;
33
34
  startedAt: string;
34
- callerId: string;
35
35
  roles?: string[] | undefined;
36
36
  }>;
37
37
  export declare const BindingSchema: z.ZodObject<{
@@ -45,14 +45,14 @@ export declare const BindingSchema: z.ZodObject<{
45
45
  callerId: z.ZodString;
46
46
  lastHeartbeat: z.ZodString;
47
47
  }, "strip", z.ZodTypeAny, {
48
+ callerId: string;
48
49
  lastHeartbeat: string;
49
50
  startedAt: string;
50
51
  roles: string[];
51
- callerId: string;
52
52
  }, {
53
+ callerId: string;
53
54
  lastHeartbeat: string;
54
55
  startedAt: string;
55
- callerId: string;
56
56
  roles?: string[] | undefined;
57
57
  }>>;
58
58
  }, "strip", z.ZodTypeAny, {
@@ -61,10 +61,10 @@ export declare const BindingSchema: z.ZodObject<{
61
61
  pid: number;
62
62
  scope: string;
63
63
  instances: Record<string, {
64
+ callerId: string;
64
65
  lastHeartbeat: string;
65
66
  startedAt: string;
66
67
  roles: string[];
67
- callerId: string;
68
68
  }>;
69
69
  }, {
70
70
  lastHeartbeat: string;
@@ -72,9 +72,9 @@ export declare const BindingSchema: z.ZodObject<{
72
72
  pid: number;
73
73
  scope: string;
74
74
  instances: Record<string, {
75
+ callerId: string;
75
76
  lastHeartbeat: string;
76
77
  startedAt: string;
77
- callerId: string;
78
78
  roles?: string[] | undefined;
79
79
  }>;
80
80
  }>;
@@ -59,6 +59,13 @@ export declare function getCallerBinding(projectRoot: string, callerId: string):
59
59
  * for the binding object (callerId must match the file stem, peakSessionId
60
60
  * must be a valid session id, projectRoot is canonicalized). Idempotent:
61
61
  * re-writing the same callerId overwrites the file.
62
+ *
63
+ * Slice 2026-08-06-session-cacde8-A.5b: writes via `atomicWriteJson`
64
+ * (temp-file-then-rename) so a power-loss mid-write cannot leave the
65
+ * caller-binding file in a half-truncated state. `atomicWriteJson`
66
+ * owns its own `mkdirSync(dir, { recursive: true })`, so the inline
67
+ * mkdir block is removed. The previous `writeFileSync` path was a
68
+ * documented 4.0.14 carry-forward micro-fix from QA's issue #1.
62
69
  */
63
70
  export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
64
71
  /**
@@ -16,9 +16,10 @@
16
16
  * See `.peaks/_runtime/2026-06-09-session-8bfe7d/prd/source/caller-id-contract.md`
17
17
  * for the freeze-in contract.
18
18
  */
19
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
20
20
  import { createHash } from 'node:crypto';
21
- import { dirname, join, resolve } from 'node:path';
21
+ import { join, resolve } from 'node:path';
22
+ import { atomicWriteJson } from '../ide/shared/atomic-json.js';
22
23
  import { CALLER_ID_REGEX } from './caller-id-types.js';
23
24
  import { getSessionDir } from './getSessionDir.js';
24
25
  /**
@@ -100,21 +101,24 @@ export function getCallerBinding(projectRoot, callerId) {
100
101
  * for the binding object (callerId must match the file stem, peakSessionId
101
102
  * must be a valid session id, projectRoot is canonicalized). Idempotent:
102
103
  * re-writing the same callerId overwrites the file.
104
+ *
105
+ * Slice 2026-08-06-session-cacde8-A.5b: writes via `atomicWriteJson`
106
+ * (temp-file-then-rename) so a power-loss mid-write cannot leave the
107
+ * caller-binding file in a half-truncated state. `atomicWriteJson`
108
+ * owns its own `mkdirSync(dir, { recursive: true })`, so the inline
109
+ * mkdir block is removed. The previous `writeFileSync` path was a
110
+ * documented 4.0.14 carry-forward micro-fix from QA's issue #1.
103
111
  */
104
112
  export function setCallerBinding(projectRoot, callerId, binding) {
105
113
  if (binding.callerId !== callerId) {
106
114
  throw new Error(`setCallerBinding: binding.callerId "${binding.callerId}" does not match callerId "${callerId}"`);
107
115
  }
108
116
  const bindingPath = getCallerBindingFile(projectRoot, callerId);
109
- const dir = dirname(bindingPath);
110
- if (!existsSync(dir)) {
111
- mkdirSync(dir, { recursive: true });
112
- }
113
117
  const payload = {
114
118
  ...binding,
115
119
  projectRoot: resolve(binding.projectRoot)
116
120
  };
117
- writeFileSync(bindingPath, JSON.stringify(payload, null, 2), 'utf8');
121
+ atomicWriteJson(bindingPath, payload);
118
122
  }
119
123
  /**
120
124
  * Enumerate the per-caller binding files under
@@ -18,6 +18,12 @@
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
+ /**
22
+ * Test-only seam: reset the module-scoped `lastResolvedOuter` so
23
+ * individual tests start with a clean slate. NOT exported as part
24
+ * of the public API; marked TODO(g2) for the v2.14.0 grace window.
25
+ */
26
+ export declare function _resetLastResolvedOuterForTest(): void;
21
27
  export type EnsureSessionOptions = {
22
28
  /**
23
29
  * When `true`, suppress the outer-session-mismatch auto-rotation.
@@ -52,7 +58,7 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
52
58
  * binding changes — and the rotation is surfaced in the return value
53
59
  * so the CLI can include it in the JSON envelope.
54
60
  *
55
- * Rotation is suppressed in three cases (all false-positive guards):
61
+ * Rotation is suppressed in four cases (all false-positive guards):
56
62
  *
57
63
  * 1. The current outer session id is undefined (no env var set) —
58
64
  * there is no signal to compare against, defaulting to "do not
@@ -63,6 +69,16 @@ export declare function ensureSession(projectRoot: string): Promise<string>;
63
69
  * 3. The bound session's recorded outer session id matches the
64
70
  * current one (reconnect within the same Claude session) — this
65
71
  * is the common case, not a swap.
72
+ * 4. Slice 2026-08-06-session-cacde8-A.3: same-process re-resolve —
73
+ * `lastResolvedOuter.value` was already set to `currentOuterSessionId`
74
+ * in this process and `boundOuter` equals that same value. Long-
75
+ * running presence-lease writers that re-resolve within a single
76
+ * process must not rotate on every CLI heartbeat. The module-scoped
77
+ * state is per-process; each fresh CLI invocation starts with
78
+ * `lastResolvedOuter === null`, so this guard cannot leak across
79
+ * processes. The 4th guard ONLY fires when `lastResolvedOuter.value`
80
+ * is a defined string (an undefined last-resolved value still
81
+ * falls through to the legacy comparison path).
66
82
  *
67
83
  * When `options.skipRotateOnOuterMismatch === true`, the rotation
68
84
  * check is short-circuited and the binding is preserved (opt-out for
@@ -23,6 +23,8 @@ import { dirname, join } from 'node:path';
23
23
  import { randomBytes } from 'node:crypto';
24
24
  import { initWorkspace } from '../workspace/workspace-service.js';
25
25
  import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
26
+ import { getCallerBinding, setCallerBinding, } from './caller-binding-service.js';
27
+ import { resolveCallerProjection } from './resolve-caller-id.js';
26
28
  import { getSessionId, getSessionIdCanonical, getSessionMeta, rotateSessionBinding, setSessionMeta } from './session-manager.js';
27
29
  // --- Lower-level helpers the bridge needs (moved verbatim) ---
28
30
  const SESSION_FILE = join('_runtime', 'session.json');
@@ -131,42 +133,133 @@ function writeSessionMeta(projectRoot, sessionId, meta) {
131
133
  }
132
134
  writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
133
135
  }
136
+ // Slice 2026-08-06-session-cacde8-A.3: module-scoped state populated on
137
+ // every non-throw `getCurrentOuterSessionId` call. The 4th rotation
138
+ // guard in `ensureSessionWithRotation` short-circuits when both
139
+ // `currentOuterSessionId` and `boundOuter` equal the last-resolved
140
+ // value in this process — a common case for long-running presence-
141
+ // lease writers that re-resolve the outer session id within the same
142
+ // process. Each CLI invocation is a fresh process, so this guard is
143
+ // per-invocation and cannot leak across processes.
144
+ let lastResolvedOuter = null;
134
145
  function getCurrentOuterSessionId(projectRoot) {
146
+ let resolved;
135
147
  const peaks = process.env.PEAKS_OUTER_SESSION_ID;
136
- if (typeof peaks === 'string' && peaks.length > 0)
137
- return peaks;
138
- const claude = process.env.CLAUDE_CODE_SESSION_ID;
139
- if (typeof claude === 'string' && claude.length > 0)
140
- return claude;
141
- // Slice 2026-08-06-session-outer-cache (G1): when the peaks CLI runs
142
- // as a sub-process of Claude Code (or any IDE that does not export
143
- // CLAUDE_CODE_SESSION_ID into the child env), the env vars above are
144
- // undefined. Fall back to the per-project file cache written by the
145
- // SessionStart hook via `peaks outer-cache write`. The file lives
146
- // under `.peaks/_runtime/` (gitignored) so no .gitignore change is
147
- // required. Any IO error or non-string `outerSessionId` field is
148
- // treated as a cache miss — never throw.
149
- if (projectRoot !== undefined) {
150
- const cachePath = join(projectRoot, '.peaks', OUTER_SESSION_CACHE_FILE);
151
- if (existsSync(cachePath)) {
152
- try {
153
- const raw = readFileSync(cachePath, 'utf8');
154
- const parsed = JSON.parse(raw);
155
- if (parsed !== null &&
156
- typeof parsed === 'object' &&
157
- typeof parsed.outerSessionId === 'string' &&
158
- (parsed.outerSessionId).length > 0) {
159
- return parsed.outerSessionId;
148
+ if (typeof peaks === 'string' && peaks.length > 0) {
149
+ resolved = peaks;
150
+ }
151
+ else {
152
+ const claude = process.env.CLAUDE_CODE_SESSION_ID;
153
+ if (typeof claude === 'string' && claude.length > 0) {
154
+ resolved = claude;
155
+ }
156
+ else {
157
+ // Slice 2026-08-06-session-outer-cache (G1): when the peaks CLI runs
158
+ // as a sub-process of Claude Code (or any IDE that does not export
159
+ // CLAUDE_CODE_SESSION_ID into the child env), the env vars above are
160
+ // undefined. Fall back to the per-project file cache written by the
161
+ // SessionStart hook via `peaks outer-cache write`. The file lives
162
+ // under `.peaks/_runtime/` (gitignored) so no .gitignore change is
163
+ // required. Any IO error or non-string `outerSessionId` field is
164
+ // treated as a cache miss — never throw.
165
+ if (projectRoot !== undefined) {
166
+ const cachePath = join(projectRoot, '.peaks', OUTER_SESSION_CACHE_FILE);
167
+ if (existsSync(cachePath)) {
168
+ try {
169
+ const raw = readFileSync(cachePath, 'utf8');
170
+ const parsed = JSON.parse(raw);
171
+ if (parsed !== null &&
172
+ typeof parsed === 'object' &&
173
+ typeof parsed.outerSessionId === 'string' &&
174
+ (parsed.outerSessionId).length > 0) {
175
+ resolved = parsed.outerSessionId;
176
+ }
177
+ }
178
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
179
+ // fall through — file missing / malformed JSON / IO error → undefined
180
+ }
160
181
  }
161
182
  }
162
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
163
- // fall through — file missing / malformed JSON / IO error → undefined
164
- }
165
183
  }
166
184
  }
167
- return undefined;
185
+ // Record the resolved value on every non-throw call (env hit,
186
+ // cache hit, or undefined fallback). The 4th rotation guard reads
187
+ // this field to short-circuit same-process re-resolves.
188
+ lastResolvedOuter = { value: resolved, resolvedAt: Date.now() };
189
+ return resolved;
190
+ }
191
+ /**
192
+ * Test-only seam: reset the module-scoped `lastResolvedOuter` so
193
+ * individual tests start with a clean slate. NOT exported as part
194
+ * of the public API; marked TODO(g2) for the v2.14.0 grace window.
195
+ */
196
+ export function _resetLastResolvedOuterForTest() {
197
+ lastResolvedOuter = null;
198
+ }
199
+ /**
200
+ * Slice 2026-08-06-session-cacde8-A.5a: resolve the current callerId
201
+ * (if any) and look up the per-caller binding. Returns the bound
202
+ * sessionId + creation timestamp, or `null` when caller-id
203
+ * resolution fails (`PEAKS_CALLER_NOT_RESOLVED`) or no per-caller
204
+ * file exists. The per-caller file is the 4.0.8 source of truth;
205
+ * `ensureSession` consults it BEFORE `readSessionFile` so a binding
206
+ * written by a previous call from the same caller is preferred over
207
+ * a stale `session.json` (the legacy single-file binding).
208
+ */
209
+ function resolveCallerBindingForEnsure(projectRoot) {
210
+ let projection;
211
+ try {
212
+ projection = resolveCallerProjection({ projectRoot, env: process.env });
213
+ }
214
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
215
+ return null;
216
+ }
217
+ try {
218
+ const binding = getCallerBinding(projectRoot, projection.callerId);
219
+ if (binding === null)
220
+ return null;
221
+ return {
222
+ sessionId: binding.peakSessionId,
223
+ createdAt: binding.createdAt,
224
+ callerId: projection.callerId
225
+ };
226
+ }
227
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
228
+ return null;
229
+ }
168
230
  }
169
231
  export async function ensureSession(projectRoot) {
232
+ // Slice 2026-08-06-session-cacde8-A.5a: 3-tier read order —
233
+ // (1) per-caller binding (caller-keyed, primary), (2) legacy
234
+ // `session.json`, (3) fresh-generate. The per-caller lookup is
235
+ // preferred when `resolveCallerProjection` succeeds AND the
236
+ // per-caller file exists. When `resolveCallerProjection` throws
237
+ // `PEAKS_CALLER_NOT_RESOLVED` we fall through to the session.json
238
+ // path; the legacy fallback preserves existing CLI behaviour for
239
+ // projects without an active IDE adapter.
240
+ const callerBinding = resolveCallerBindingForEnsure(projectRoot);
241
+ if (callerBinding !== null) {
242
+ // Already-bound path via caller-binding (primary). Stamp the
243
+ // current outer-session-id onto the bound session's meta (G3).
244
+ const outerSessionId = getCurrentOuterSessionId(projectRoot);
245
+ if (outerSessionId !== undefined) {
246
+ setSessionMeta(projectRoot, callerBinding.sessionId, { outerSessionId });
247
+ }
248
+ // Slice 2026-08-06-session-cacde8-A.5a: dual-write — also write
249
+ // the legacy `session.json` (denormalized cache) when missing,
250
+ // so legacy consumers (e.g. `getSessionId` callers without a
251
+ // resolved callerId) still find the binding. The per-caller
252
+ // file is the source of truth; `session.json` is regenerated
253
+ // here for back-compat.
254
+ if (readSessionFile(projectRoot) === null) {
255
+ writeSessionFile(projectRoot, {
256
+ sessionId: callerBinding.sessionId,
257
+ createdAt: callerBinding.createdAt,
258
+ projectRoot
259
+ });
260
+ }
261
+ return callerBinding.sessionId;
262
+ }
170
263
  const existing = readSessionFile(projectRoot);
171
264
  if (existing) {
172
265
  // Slice 2026-08-06-session-outer-cache (G3 / AC8-AC11): on every
@@ -210,6 +303,39 @@ export async function ensureSession(projectRoot) {
210
303
  createdAt: now,
211
304
  projectRoot
212
305
  };
306
+ // Slice 2026-08-06-session-cacde8-A.5a: dual-write on fresh-generate.
307
+ // The per-caller file is the 4.0.8 source of truth; the legacy
308
+ // `session.json` is a denormalized cache for consumers that have
309
+ // not migrated to `getCallerBinding`. Per-caller file is written
310
+ // FIRST; on partial-write failure of either file, the next
311
+ // `ensureSession` will re-derive `session.json` from the
312
+ // per-caller file via the tier-1 read path above.
313
+ const newCallerId = (() => {
314
+ try {
315
+ return resolveCallerProjection({ projectRoot, env: process.env }).callerId;
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ })();
321
+ if (newCallerId !== null) {
322
+ const payload = {
323
+ callerId: newCallerId,
324
+ peakSessionId: sessionId,
325
+ projectRoot,
326
+ createdAt: now,
327
+ lastActivityAt: now,
328
+ skill: 'peaks-code',
329
+ mode: 'unknown',
330
+ gate: 'startup'
331
+ };
332
+ try {
333
+ setCallerBinding(projectRoot, newCallerId, payload);
334
+ }
335
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
336
+ // best effort; session.json write below is the legacy fallback
337
+ }
338
+ }
213
339
  writeSessionFile(projectRoot, info);
214
340
  await initWorkspace({ projectRoot, sessionId });
215
341
  // Initialize session metadata inside the session directory.
@@ -238,7 +364,7 @@ export async function ensureSession(projectRoot) {
238
364
  * binding changes — and the rotation is surfaced in the return value
239
365
  * so the CLI can include it in the JSON envelope.
240
366
  *
241
- * Rotation is suppressed in three cases (all false-positive guards):
367
+ * Rotation is suppressed in four cases (all false-positive guards):
242
368
  *
243
369
  * 1. The current outer session id is undefined (no env var set) —
244
370
  * there is no signal to compare against, defaulting to "do not
@@ -249,6 +375,16 @@ export async function ensureSession(projectRoot) {
249
375
  * 3. The bound session's recorded outer session id matches the
250
376
  * current one (reconnect within the same Claude session) — this
251
377
  * is the common case, not a swap.
378
+ * 4. Slice 2026-08-06-session-cacde8-A.3: same-process re-resolve —
379
+ * `lastResolvedOuter.value` was already set to `currentOuterSessionId`
380
+ * in this process and `boundOuter` equals that same value. Long-
381
+ * running presence-lease writers that re-resolve within a single
382
+ * process must not rotate on every CLI heartbeat. The module-scoped
383
+ * state is per-process; each fresh CLI invocation starts with
384
+ * `lastResolvedOuter === null`, so this guard cannot leak across
385
+ * processes. The 4th guard ONLY fires when `lastResolvedOuter.value`
386
+ * is a defined string (an undefined last-resolved value still
387
+ * falls through to the legacy comparison path).
252
388
  *
253
389
  * When `options.skipRotateOnOuterMismatch === true`, the rotation
254
390
  * check is short-circuited and the binding is preserved (opt-out for
@@ -277,6 +413,26 @@ export async function ensureSessionWithRotation(projectRoot, options) {
277
413
  if (boundSessionId !== null && currentOuterSessionId !== undefined) {
278
414
  const boundMeta = getSessionMeta(projectRoot, boundSessionId);
279
415
  const boundOuter = boundMeta?.outerSessionId;
416
+ // Slice 2026-08-06-session-cacde8-A.3: 4th guard — same-process
417
+ // re-resolve. When `lastResolvedOuter` was already set to the
418
+ // current outer AND the bound session's recorded outer equals
419
+ // the same value, the rotation decision was already evaluated
420
+ // in this process. Without this short-circuit, a long-running
421
+ // presence-lease writer would rotate on every heartbeat within
422
+ // the same process. Skipping only fires when `lastResolvedOuter`
423
+ // is a defined string (undefined last-resolved values fall
424
+ // through to the legacy comparison path so the first-ever
425
+ // resolve still works).
426
+ if (lastResolvedOuter?.value !== undefined &&
427
+ lastResolvedOuter.value === currentOuterSessionId &&
428
+ lastResolvedOuter.value === boundOuter) {
429
+ const sessionId = await ensureSession(projectRoot);
430
+ return {
431
+ sessionId,
432
+ previousSessionId: null,
433
+ rotationReason: null
434
+ };
435
+ }
280
436
  if (typeof boundOuter === 'string' &&
281
437
  boundOuter.length > 0 &&
282
438
  boundOuter !== currentOuterSessionId &&
@@ -11,6 +11,8 @@ import { dirname, join, resolve } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { projectRootsMatch, stableRealPath } from '../../shared/path-utils.js';
13
13
  import { ensureSession } from './session-binding-bridge.js';
14
+ import { getCallerBinding } from './caller-binding-service.js';
15
+ import { resolveCallerProjection } from './resolve-caller-id.js';
14
16
  // As of slice 2026-06-05-peaks-runtime-layer the project-level session
15
17
  // binding lives under `.peaks/_runtime/session.json`. The legacy
16
18
  // `.peaks/.session.json` path is preserved as a read-only fallback for one
@@ -398,6 +400,18 @@ export { ensureSession, ensureSessionWithRotation } from './session-binding-brid
398
400
  * @returns Session ID or null
399
401
  */
400
402
  export function getSessionId(projectRoot) {
403
+ // Slice 2026-08-06-session-cacde8-A.5a: caller-binding becomes
404
+ // primary source. Read order is (1) `getCallerBinding` if
405
+ // `resolveCallerProjection` succeeds, (2) `readSessionFile`
406
+ // (the legacy session.json), (3) null (no binding). The legacy
407
+ // session.json is preserved as a fallback so a project without
408
+ // a caller-id resolution still resolves its binding (e.g. CLI
409
+ // run from a stock shell with no IDE adapter). `PEAKS_CALLER_NOT_RESOLVED`
410
+ // falls through to the session.json path; no caller-facing exit
411
+ // change.
412
+ const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
413
+ if (fromCallerBinding !== null)
414
+ return fromCallerBinding;
401
415
  const info = readSessionFile(projectRoot);
402
416
  return info?.sessionId ?? null;
403
417
  }
@@ -429,9 +443,32 @@ export function getSessionId(projectRoot) {
429
443
  * so this variant is opt-in.
430
444
  */
431
445
  export function getSessionIdCanonical(projectRoot) {
446
+ // Slice 2026-08-06-session-cacde8-A.5a: same caller-binding primary
447
+ // lookup as `getSessionId`; fall back to the canonical-fallback
448
+ // `readSessionFileCanonical` if caller-binding is absent / unresolved.
449
+ const fromCallerBinding = getSessionIdFromCallerBinding(projectRoot);
450
+ if (fromCallerBinding !== null)
451
+ return fromCallerBinding;
432
452
  const info = readSessionFileCanonical(projectRoot);
433
453
  return info?.sessionId ?? null;
434
454
  }
455
+ /**
456
+ * Internal helper: read the caller-binding primary source.
457
+ * Returns `null` when caller-id resolution fails
458
+ * (`PEAKS_CALLER_NOT_RESOLVED`), when no per-caller file exists,
459
+ * or when the file is malformed. NOT exported; the public surface
460
+ * is still `getSessionId` / `getSessionIdCanonical`.
461
+ */
462
+ function getSessionIdFromCallerBinding(projectRoot) {
463
+ try {
464
+ const projection = resolveCallerProjection({ projectRoot, env: process.env });
465
+ const binding = getCallerBinding(projectRoot, projection.callerId);
466
+ return binding?.peakSessionId ?? null;
467
+ }
468
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
469
+ return null;
470
+ }
471
+ }
435
472
  /**
436
473
  * Get the absolute path to the current session directory.
437
474
  * Creates the session if it doesn't exist.
@@ -319,7 +319,19 @@ export function setSkillPresence(skill, mode, gate, projectRootOverride) {
319
319
  import('./presence-lease-service.js'),
320
320
  ]);
321
321
  const projection = resolveCallerProjection({ projectRoot, env: process.env });
322
- const workflowId = `wf-${sessionId}-compat`;
322
+ // Slice 2026-08-06-session-cacde8-A.4: derive the legacy compat
323
+ // shim's workflowId from `projection.callerId` (caller-keyed,
324
+ // matching the 4.0.8 caller-binding direction) instead of
325
+ // `sessionId` (sid-keyed, pre-4.0.8). `slice(0, 189)` caps the
326
+ // callerId to 189 chars so the resulting workflowId
327
+ // (`wf-` + callerId + `-compat`) stays under the 200-char
328
+ // `WORKFLOW_ID_REGEX = /^[a-zA-Z0-9._-]{1,200}$/` cap. The
329
+ // truncation is loss-free for regex purposes — callerId is
330
+ // already regex-conformant before the slice. Existing 4.0.14
331
+ // leases with `wf-<sid>-compat` become orphans and are GC'd
332
+ // by the 24h30m stale-started GC at
333
+ // presence-lease-service.ts:42.
334
+ const workflowId = `wf-${projection.callerId.slice(0, 189)}-compat`;
323
335
  const result = leaseMod.setPresenceLease({
324
336
  projectRoot,
325
337
  sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.14",
3
+ "version": "4.0.15",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -100,9 +100,9 @@
100
100
  "headroom-ai": "0.22.4",
101
101
  "yaml": "^2.9.0",
102
102
  "zod": "^3.25.76",
103
- "peaks-loop-shared": "0.0.45",
104
- "peaks-loop-shared-channel": "0.0.22",
105
- "peaks-loop-mut": "0.1.18"
103
+ "peaks-loop-mut": "0.1.19",
104
+ "peaks-loop-shared-channel": "0.0.23",
105
+ "peaks-loop-shared": "0.0.46"
106
106
  },
107
107
  "peerDependencies": {
108
108
  "@alibaba-group/open-code-review": "1.3.1"