peaks-loop 4.0.13 → 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 +33 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/outer-cache-commands.d.ts +11 -0
- package/dist/cli/commands/outer-cache-commands.js +127 -0
- package/dist/services/session/binding-store.d.ts +6 -6
- package/dist/services/session/caller-binding-service.d.ts +7 -0
- package/dist/services/session/caller-binding-service.js +11 -7
- package/dist/services/session/session-binding-bridge.d.ts +17 -1
- package/dist/services/session/session-binding-bridge.js +221 -12
- package/dist/services/session/session-manager.js +37 -0
- package/dist/services/skills/hooks-settings-service.js +22 -2
- package/dist/services/skills/outer-cache-hook-constants.d.ts +24 -0
- package/dist/services/skills/outer-cache-hook-constants.js +24 -0
- package/dist/services/skills/skill-presence-service.js +13 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
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
|
+
|
|
24
|
+
## 4.0.14 — 2026-08-06 (outer-session cache + ensureSession meta over-coverage)
|
|
25
|
+
|
|
26
|
+
**Fixes the "5 terminals / 5 sessions stuck on 3fe1be" bug** (slice `2026-08-06-session-outer-cache-and-meta-coverage`, commit `f02a9b45`):
|
|
27
|
+
- `src/services/session/session-binding-bridge.ts` `getCurrentOuterSessionId(projectRoot)` now resolves `PEAKS_OUTER_SESSION_ID` → `CLAUDE_CODE_SESSION_ID` → file-cache at `.peaks/_runtime/.outer-session-cache.json` → `undefined`. Any IO error or non-string `outerSessionId` is a cache-miss (no throw). The cache file lives under the existing `.peaks/_runtime/` gitignore parent rule — no `.gitignore` modification.
|
|
28
|
+
- Same file: `ensureSession` early-return path now calls `setSessionMeta(projectRoot, existing.sessionId, { outerSessionId })` BEFORE returning. The on-disk `.peaks/_runtime/<sid>/session.json` always reflects the latest outerSessionId (not a stale value captured at first-bind time). All other meta fields (title / skill / mode / gate / createdAt) preserved via read-modify-write; `lastActivity` bumped to `now`.
|
|
29
|
+
- `src/services/skills/hooks-settings-service.ts` `resolveHookEntries('claude-code')` now appends a SessionStart entry that runs `peaks outer-cache write --project ...` to keep the cache in sync with the active Claude Code session. Uninstall strips the entry alongside the gate-enforce entry. Multi-binary drift guard (slice 2026-08-05) still fires correctly because the SessionStart entry uses the same `peaks` binary.
|
|
30
|
+
- New CLI: `peaks outer-cache write` (no flags) + `peaks outer-cache read` (returns `{ missing: true }` envelope on absent / malformed / IO error). Write exits 1 with `OUTER_CACHE_NO_ENV` when neither `PEAKS_OUTER_SESSION_ID` nor `CLAUDE_CODE_SESSION_ID` is set.
|
|
31
|
+
- Tests: `tests/unit/session/get-current-outer-session-id.test.ts` (10 cases pinning env > cache > undefined ordering, cache-miss / malformed JSON / IO error tolerance) + `tests/unit/session/ensure-session-meta-coverage.test.ts` (7 cases pinning AC8-AC11 over-coverage). Regression sweep: 179/179 PASS / 1 SKIP (pre-existing Win-only conditional).
|
|
32
|
+
- A.3 / A.4 / A.5 (workflowId by callerId + `session.json` migration + cross-outer rotation cases) explicitly reserved for a future session per PRD NG1-NG4.
|
|
33
|
+
|
|
34
|
+
**Lockstep bump.** peaks-loop-shared `0.0.43 → 0.0.44` (CLI_VERSION re-stamped to 4.0.14); peaks-loop-mut `0.1.16 → 0.1.17`; peaks-loop-shared-channel `0.0.20 → 0.0.21`.
|
|
35
|
+
|
|
3
36
|
## 4.0.13 — 2026-08-06 (statusline empty-render fix + sid-only marker + multi-binary drift guard)
|
|
4
37
|
|
|
5
38
|
**Statusline callerId fallback + active `[short-sid]` suffix** (slice `2026-08-05-statusline-empty-render-and-short-sid-suffix`, commit `4be37d08`):
|
|
@@ -36,6 +36,7 @@ import { registerWorkflowEvalCommands } from './loop-eval-commands.js';
|
|
|
36
36
|
import { registerMutCommands } from './mut-commands.js';
|
|
37
37
|
import { registerObservabilityCommands } from './observability-commands.js';
|
|
38
38
|
import { registerOpenSpecCommands } from './openspec-commands.js';
|
|
39
|
+
import { registerOuterCacheCommands } from './outer-cache-commands.js';
|
|
39
40
|
import { registerPerfAuditCommands } from './perf-audit-commands.js';
|
|
40
41
|
import { registerPerfCommands } from './perf-commands.js';
|
|
41
42
|
import { registerPlaywrightCommands } from './playwright-commands.js';
|
|
@@ -127,6 +128,7 @@ const REGISTRATIONS = [
|
|
|
127
128
|
['adapter-commands-s2a', registerAdapterS2ACommands], ['polyrepo-commands', registerPolyrepoCommands],
|
|
128
129
|
['worktree-auth-commands', registerWorktreeAuthCommand],
|
|
129
130
|
['session-spill-demo', registerSpillDemoCommand],
|
|
131
|
+
['outer-cache-commands', registerOuterCacheCommands],
|
|
130
132
|
];
|
|
131
133
|
function dispatchRegister(register, program, io) {
|
|
132
134
|
if (register.length <= 1) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
3
|
+
export type OuterCacheWriteOptions = {
|
|
4
|
+
readonly project?: string;
|
|
5
|
+
readonly json?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type OuterCacheReadOptions = {
|
|
8
|
+
readonly project?: string;
|
|
9
|
+
readonly json?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function registerOuterCacheCommands(program: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-08-06-session-outer-cache (G1 / G2) — per-project outer-session
|
|
3
|
+
* cache CLI surface.
|
|
4
|
+
*
|
|
5
|
+
* Writes and reads `.peaks/_runtime/.outer-session-cache.json` so that
|
|
6
|
+
* peaks CLI sub-processes (which typically do NOT inherit
|
|
7
|
+
* `CLAUDE_CODE_SESSION_ID` from the parent shell) can resolve the
|
|
8
|
+
* current outer session id via `getCurrentOuterSessionId(projectRoot)`
|
|
9
|
+
* in `src/services/session/session-binding-bridge.ts`.
|
|
10
|
+
*
|
|
11
|
+
* The SessionStart hook wired by `peaks hooks install` invokes
|
|
12
|
+
* `peaks outer-cache write` to keep this file in sync with the active
|
|
13
|
+
* Claude Code / Trae / IDE session. The file is under `.peaks/_runtime/`
|
|
14
|
+
* (gitignored), so no `.gitignore` change is required.
|
|
15
|
+
*
|
|
16
|
+
* Subcommand surface:
|
|
17
|
+
* - `peaks outer-cache write` (no flags) — read PEAKS_OUTER_SESSION_ID
|
|
18
|
+
* ?? CLAUDE_CODE_SESSION_ID, write the cache file, return JSON
|
|
19
|
+
* envelope with `{ outerSessionId, capturedAt, cachePath, written }`.
|
|
20
|
+
* - `peaks outer-cache read` — return
|
|
21
|
+
* `{ outerSessionId, capturedAt, cachePath }` when present or
|
|
22
|
+
* `{ missing: true, cachePath }` when not. Never throws on
|
|
23
|
+
* malformed JSON / IO error.
|
|
24
|
+
*
|
|
25
|
+
* Both subcommands accept `--project <path>` (defaults to cwd / git
|
|
26
|
+
* root) and `--json` (default in TTY-less invocations).
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { fail, ok } from 'peaks-loop-shared/result';
|
|
31
|
+
import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
|
|
32
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
33
|
+
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
34
|
+
const OUTER_SESSION_CACHE_REL = join('.peaks', '_runtime', '.outer-session-cache.json');
|
|
35
|
+
function resolveCachePath(projectRoot) {
|
|
36
|
+
return join(projectRoot, OUTER_SESSION_CACHE_REL);
|
|
37
|
+
}
|
|
38
|
+
function readEnvOuter() {
|
|
39
|
+
const peaks = process.env.PEAKS_OUTER_SESSION_ID;
|
|
40
|
+
if (typeof peaks === 'string' && peaks.length > 0)
|
|
41
|
+
return peaks;
|
|
42
|
+
const claude = process.env.CLAUDE_CODE_SESSION_ID;
|
|
43
|
+
if (typeof claude === 'string' && claude.length > 0)
|
|
44
|
+
return claude;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
function readCacheFile(cachePath) {
|
|
48
|
+
if (!existsSync(cachePath))
|
|
49
|
+
return { ok: false, missing: true };
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(cachePath, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed !== null &&
|
|
54
|
+
typeof parsed === 'object' &&
|
|
55
|
+
typeof parsed.outerSessionId === 'string' &&
|
|
56
|
+
(parsed.outerSessionId).length > 0 &&
|
|
57
|
+
typeof parsed.capturedAt === 'string') {
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
outerSessionId: parsed.outerSessionId,
|
|
61
|
+
capturedAt: parsed.capturedAt
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: false, missing: true };
|
|
65
|
+
}
|
|
66
|
+
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
67
|
+
return { ok: false, missing: true };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function registerOuterCacheCommands(program, io) {
|
|
71
|
+
const outerCache = program
|
|
72
|
+
.command('outer-cache')
|
|
73
|
+
.description('Read / write the per-project outer-session cache file (.peaks/_runtime/.outer-session-cache.json). The SessionStart hook installed by `peaks hooks install` keeps this file in sync with the live Claude Code / Trae / IDE session so peaks CLI sub-processes (which typically do NOT inherit CLAUDE_CODE_SESSION_ID) can resolve the current outer session via getCurrentOuterSessionId(projectRoot).');
|
|
74
|
+
addJsonOption(outerCache
|
|
75
|
+
.command('write')
|
|
76
|
+
.description('Write the current outer-session-id (PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID) into the per-project cache file. Idempotent: re-runs overwrite. Exits 1 with OUTER_CACHE_NO_ENV when neither env var is set.')
|
|
77
|
+
.option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
|
|
78
|
+
const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
79
|
+
const cachePath = resolveCachePath(projectRoot);
|
|
80
|
+
const outerSessionId = readEnvOuter();
|
|
81
|
+
if (outerSessionId === undefined) {
|
|
82
|
+
printResult(io, fail('outer-cache.write', 'OUTER_CACHE_NO_ENV', 'Neither PEAKS_OUTER_SESSION_ID nor CLAUDE_CODE_SESSION_ID is set; nothing to write', { cachePath, written: false, projectRoot }, [
|
|
83
|
+
'Set PEAKS_OUTER_SESSION_ID=<id> or run from inside Claude Code / Trae / IDE so CLAUDE_CODE_SESSION_ID is exported',
|
|
84
|
+
'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically'
|
|
85
|
+
]), options.json === true);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const capturedAt = new Date().toISOString();
|
|
90
|
+
const payload = { outerSessionId, capturedAt };
|
|
91
|
+
try {
|
|
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);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
printResult(io, fail('outer-cache.write', 'OUTER_CACHE_WRITE_FAILED', `Failed to write outer-session cache: ${message}`, { cachePath, written: false, projectRoot, outerSessionId, capturedAt }, [message]), options.json === true);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
printResult(io, ok('outer-cache.write', { cachePath, written: true, projectRoot, outerSessionId, capturedAt }, [], [`Wrote ${cachePath}; getCurrentOuterSessionId() will resolve to "${outerSessionId}" until the next SessionStart fires.`]), options.json === true);
|
|
110
|
+
});
|
|
111
|
+
addJsonOption(outerCache
|
|
112
|
+
.command('read')
|
|
113
|
+
.description('Read the current value of the per-project outer-session cache. Returns { missing: true, cachePath } when the file is absent / malformed / empty; never throws on IO error.')
|
|
114
|
+
.option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
|
|
115
|
+
const projectRoot = options.project ?? (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
116
|
+
const cachePath = resolveCachePath(projectRoot);
|
|
117
|
+
const result = readCacheFile(cachePath);
|
|
118
|
+
if (result.ok) {
|
|
119
|
+
printResult(io, ok('outer-cache.read', { cachePath, missing: false, projectRoot, outerSessionId: result.outerSessionId, capturedAt: result.capturedAt }, [], [`outer-session-id resolved: ${result.outerSessionId} (captured ${result.capturedAt})`]), options.json === true);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
printResult(io, ok('outer-cache.read', { cachePath, missing: true, projectRoot }, ['No outer-session cache present — getCurrentOuterSessionId() will return undefined for this project.'], [
|
|
123
|
+
'Re-run `peaks hooks install` to wire the SessionStart hook that writes this cache automatically',
|
|
124
|
+
'Or set PEAKS_OUTER_SESSION_ID=<id> so env-first resolution wins without touching the cache'
|
|
125
|
+
]), options.json === true);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -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,
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
|
-
import {
|
|
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
|
-
|
|
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
|
|
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,11 +23,20 @@ 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 {
|
|
26
|
+
import { getCallerBinding, setCallerBinding, } from './caller-binding-service.js';
|
|
27
|
+
import { resolveCallerProjection } from './resolve-caller-id.js';
|
|
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');
|
|
29
31
|
const LEGACY_SESSION_FILE = '.session.json';
|
|
30
32
|
const META_FILE = 'session.json';
|
|
33
|
+
// Slice 2026-08-06-session-outer-cache: per-project file cache for the
|
|
34
|
+
// outer (Claude Code / Trae / IDE) session id. Written by the
|
|
35
|
+
// SessionStart hook via `peaks outer-cache write` so that peaks CLI
|
|
36
|
+
// sub-processes (which do NOT inherit CLAUDE_CODE_SESSION_ID) can still
|
|
37
|
+
// resolve the current outer session. Lives under the gitignored
|
|
38
|
+
// `.peaks/_runtime/` tree, so no .gitignore change is required.
|
|
39
|
+
const OUTER_SESSION_CACHE_FILE = join('_runtime', '.outer-session-cache.json');
|
|
31
40
|
function getLegacySessionFilePath(projectRoot) {
|
|
32
41
|
return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
|
|
33
42
|
}
|
|
@@ -124,18 +133,149 @@ function writeSessionMeta(projectRoot, sessionId, meta) {
|
|
|
124
133
|
}
|
|
125
134
|
writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
|
|
126
135
|
}
|
|
127
|
-
|
|
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;
|
|
145
|
+
function getCurrentOuterSessionId(projectRoot) {
|
|
146
|
+
let resolved;
|
|
128
147
|
const peaks = process.env.PEAKS_OUTER_SESSION_ID;
|
|
129
|
-
if (typeof peaks === 'string' && peaks.length > 0)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
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
|
+
}
|
|
135
230
|
}
|
|
136
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
|
+
}
|
|
137
263
|
const existing = readSessionFile(projectRoot);
|
|
138
264
|
if (existing) {
|
|
265
|
+
// Slice 2026-08-06-session-outer-cache (G3 / AC8-AC11): on every
|
|
266
|
+
// already-bound invocation, stamp the current outer-session-id
|
|
267
|
+
// onto the bound session's meta so the on-disk
|
|
268
|
+
// `.peaks/_runtime/<sid>/session.json` always reflects the latest
|
|
269
|
+
// outer signal — not a stale value captured at session creation
|
|
270
|
+
// time. `setSessionMeta` is read-modify-write: every other field
|
|
271
|
+
// (title / skill / mode / gate / createdAt / lastActivity) is
|
|
272
|
+
// preserved. `getCurrentOuterSessionId(projectRoot)` reads env
|
|
273
|
+
// → file-cache → undefined; an undefined value is NOT written,
|
|
274
|
+
// so the previously-stamped outer (if any) survives.
|
|
275
|
+
const outerSessionId = getCurrentOuterSessionId(projectRoot);
|
|
276
|
+
if (outerSessionId !== undefined) {
|
|
277
|
+
setSessionMeta(projectRoot, existing.sessionId, { outerSessionId });
|
|
278
|
+
}
|
|
139
279
|
return existing.sessionId;
|
|
140
280
|
}
|
|
141
281
|
// Slice 007 — sub-agent session sharing. When the strict-equality
|
|
@@ -163,10 +303,46 @@ export async function ensureSession(projectRoot) {
|
|
|
163
303
|
createdAt: now,
|
|
164
304
|
projectRoot
|
|
165
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
|
+
}
|
|
166
339
|
writeSessionFile(projectRoot, info);
|
|
167
340
|
await initWorkspace({ projectRoot, sessionId });
|
|
168
|
-
// Initialize session metadata inside the session directory
|
|
169
|
-
|
|
341
|
+
// Initialize session metadata inside the session directory.
|
|
342
|
+
// Slice 2026-08-06-session-outer-cache (G1): pass `projectRoot` so
|
|
343
|
+
// the env → file-cache → undefined resolution chain also reads the
|
|
344
|
+
// SessionStart-written cache on first bind.
|
|
345
|
+
const outerSessionId = getCurrentOuterSessionId(projectRoot);
|
|
170
346
|
writeSessionMeta(projectRoot, sessionId, {
|
|
171
347
|
sessionId,
|
|
172
348
|
projectRoot,
|
|
@@ -188,7 +364,7 @@ export async function ensureSession(projectRoot) {
|
|
|
188
364
|
* binding changes — and the rotation is surfaced in the return value
|
|
189
365
|
* so the CLI can include it in the JSON envelope.
|
|
190
366
|
*
|
|
191
|
-
* Rotation is suppressed in
|
|
367
|
+
* Rotation is suppressed in four cases (all false-positive guards):
|
|
192
368
|
*
|
|
193
369
|
* 1. The current outer session id is undefined (no env var set) —
|
|
194
370
|
* there is no signal to compare against, defaulting to "do not
|
|
@@ -199,6 +375,16 @@ export async function ensureSession(projectRoot) {
|
|
|
199
375
|
* 3. The bound session's recorded outer session id matches the
|
|
200
376
|
* current one (reconnect within the same Claude session) — this
|
|
201
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).
|
|
202
388
|
*
|
|
203
389
|
* When `options.skipRotateOnOuterMismatch === true`, the rotation
|
|
204
390
|
* check is short-circuited and the binding is preserved (opt-out for
|
|
@@ -211,7 +397,10 @@ export async function ensureSession(projectRoot) {
|
|
|
211
397
|
*/
|
|
212
398
|
export async function ensureSessionWithRotation(projectRoot, options) {
|
|
213
399
|
const skipRotate = options?.skipRotateOnOuterMismatch === true;
|
|
214
|
-
|
|
400
|
+
// Slice 2026-08-06-session-outer-cache (G1): pass `projectRoot` so
|
|
401
|
+
// the rotation-decision comparison sees the SessionStart cache
|
|
402
|
+
// when the env vars are unset (sub-process / nested invocation).
|
|
403
|
+
const currentOuterSessionId = getCurrentOuterSessionId(projectRoot);
|
|
215
404
|
// Compute the rotation decision up front. We only rotate when ALL
|
|
216
405
|
// three pre-conditions hold: (a) the current outer session id is
|
|
217
406
|
// defined, (b) the bound session has a recorded outer session id,
|
|
@@ -224,6 +413,26 @@ export async function ensureSessionWithRotation(projectRoot, options) {
|
|
|
224
413
|
if (boundSessionId !== null && currentOuterSessionId !== undefined) {
|
|
225
414
|
const boundMeta = getSessionMeta(projectRoot, boundSessionId);
|
|
226
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
|
+
}
|
|
227
436
|
if (typeof boundOuter === 'string' &&
|
|
228
437
|
boundOuter.length > 0 &&
|
|
229
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.
|
|
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
|
|
5
5
|
import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
|
|
6
6
|
import { getAdapter } from '../ide/ide-registry.js';
|
|
7
|
+
import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL } from './outer-cache-hook-constants.js';
|
|
7
8
|
// --- Module-level defaults (claude-code) -----------------------------------
|
|
8
9
|
// These exports remain for backward compat — tests and downstream callers
|
|
9
10
|
// that only care about Claude Code can keep importing them. The per-IDE
|
|
@@ -181,12 +182,25 @@ export function readInstalledEntriesFromSettings(settings, ide) {
|
|
|
181
182
|
* entry's sentinel is included in the legacy sentinel set so uninstall
|
|
182
183
|
* can find + remove any stale progress-start entry that an older
|
|
183
184
|
* `peaks hooks install` may have written before this slice.
|
|
185
|
+
*
|
|
186
|
+
* Slice 2026-08-06-session-outer-cache: a SessionStart outer-cache primer
|
|
187
|
+
* entry is appended for Claude Code only (SessionStart is a Claude-Code-
|
|
188
|
+
* specific event key today).
|
|
184
189
|
*/
|
|
185
190
|
function resolveHookEntries(ide, _skipProgress = false) {
|
|
186
191
|
const spec = resolveHookSpec(ide);
|
|
187
|
-
|
|
192
|
+
const entries = [
|
|
188
193
|
{ sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
|
|
189
194
|
];
|
|
195
|
+
if (ide === 'claude-code') {
|
|
196
|
+
entries.push({
|
|
197
|
+
sentinel: HOOK_OUTER_CACHE_SENTINEL,
|
|
198
|
+
matcher: '',
|
|
199
|
+
command: HOOK_OUTER_CACHE_COMMAND,
|
|
200
|
+
event: HOOK_OUTER_CACHE_EVENT
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return entries;
|
|
190
204
|
}
|
|
191
205
|
/**
|
|
192
206
|
* Legacy sentinel set used by uninstall + status to find and remove stale
|
|
@@ -198,7 +212,13 @@ function resolveLegacySentinels(ide) {
|
|
|
198
212
|
if (ide === 'trae') {
|
|
199
213
|
return ['peaks hook handle', LEGACY_PROGRESS_START_SENTINEL];
|
|
200
214
|
}
|
|
201
|
-
|
|
215
|
+
// Slice 2026-08-06-session-outer-cache: include the SessionStart outer-cache
|
|
216
|
+
// sentinel so uninstall strips it alongside the gate-enforce entry.
|
|
217
|
+
const base = [HOOK_ENFORCE_SENTINEL, LEGACY_PROGRESS_START_SENTINEL];
|
|
218
|
+
if (ide === 'claude-code') {
|
|
219
|
+
return [...base, HOOK_OUTER_CACHE_SENTINEL];
|
|
220
|
+
}
|
|
221
|
+
return base;
|
|
202
222
|
}
|
|
203
223
|
// --- Slice 2026-07-29-worktree-layer3-deny -----------------------------------
|
|
204
224
|
// Layer 3 of the Worktree Governance 3-layer design (see
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-08-06-session-outer-cache (G2): per-IDE SessionStart hook
|
|
3
|
+
* constants for the outer-session cache primer. Extracted from
|
|
4
|
+
* `hooks-settings-service.ts` to keep that file under the Karpathy
|
|
5
|
+
* 800 LOC cap.
|
|
6
|
+
*
|
|
7
|
+
* The SessionStart hook is fired by Claude Code (and any future IDE
|
|
8
|
+
* that ships a SessionStart-style event) every time a new outer
|
|
9
|
+
* session begins. It runs `peaks outer-cache write`, which reads
|
|
10
|
+
* PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID from env and
|
|
11
|
+
* persists it to `.peaks/_runtime/.outer-session-cache.json` so the
|
|
12
|
+
* next peaks CLI sub-process can resolve the current outer session
|
|
13
|
+
* via `getCurrentOuterSessionId(projectRoot)`.
|
|
14
|
+
*
|
|
15
|
+
* Only Claude Code supports SessionStart today; future adapters opt
|
|
16
|
+
* in by extending `resolveHookEntries` and adding an adapter-driven
|
|
17
|
+
* substitution for the `${...}` project-dir placeholder.
|
|
18
|
+
*/
|
|
19
|
+
/** Sentinel substring identifying a SessionStart outer-cache hook entry. */
|
|
20
|
+
export declare const HOOK_OUTER_CACHE_SENTINEL = "peaks outer-cache write";
|
|
21
|
+
/** Default (claude-code) SessionStart hook command. */
|
|
22
|
+
export declare const HOOK_OUTER_CACHE_COMMAND = "peaks outer-cache write --project \"${CLAUDE_PROJECT_DIR}\"";
|
|
23
|
+
/** SessionStart hook event key for Claude Code. */
|
|
24
|
+
export declare const HOOK_OUTER_CACHE_EVENT = "SessionStart";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-08-06-session-outer-cache (G2): per-IDE SessionStart hook
|
|
3
|
+
* constants for the outer-session cache primer. Extracted from
|
|
4
|
+
* `hooks-settings-service.ts` to keep that file under the Karpathy
|
|
5
|
+
* 800 LOC cap.
|
|
6
|
+
*
|
|
7
|
+
* The SessionStart hook is fired by Claude Code (and any future IDE
|
|
8
|
+
* that ships a SessionStart-style event) every time a new outer
|
|
9
|
+
* session begins. It runs `peaks outer-cache write`, which reads
|
|
10
|
+
* PEAKS_OUTER_SESSION_ID ?? CLAUDE_CODE_SESSION_ID from env and
|
|
11
|
+
* persists it to `.peaks/_runtime/.outer-session-cache.json` so the
|
|
12
|
+
* next peaks CLI sub-process can resolve the current outer session
|
|
13
|
+
* via `getCurrentOuterSessionId(projectRoot)`.
|
|
14
|
+
*
|
|
15
|
+
* Only Claude Code supports SessionStart today; future adapters opt
|
|
16
|
+
* in by extending `resolveHookEntries` and adding an adapter-driven
|
|
17
|
+
* substitution for the `${...}` project-dir placeholder.
|
|
18
|
+
*/
|
|
19
|
+
/** Sentinel substring identifying a SessionStart outer-cache hook entry. */
|
|
20
|
+
export const HOOK_OUTER_CACHE_SENTINEL = 'peaks outer-cache write';
|
|
21
|
+
/** Default (claude-code) SessionStart hook command. */
|
|
22
|
+
export const HOOK_OUTER_CACHE_COMMAND = `peaks outer-cache write --project "\${CLAUDE_PROJECT_DIR}"`;
|
|
23
|
+
/** SessionStart hook event key for Claude Code. */
|
|
24
|
+
export const HOOK_OUTER_CACHE_EVENT = 'SessionStart';
|
|
@@ -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
|
-
|
|
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.
|
|
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-
|
|
104
|
-
"peaks-loop-
|
|
105
|
-
"peaks-loop-shared
|
|
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"
|