fluffy-context 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +99 -8
  2. package/dist/src/agent/index.d.ts +2 -0
  3. package/dist/src/agent/index.js +1 -0
  4. package/dist/src/cli/main.js +69 -5
  5. package/dist/src/cognition/filesystem-observer.d.ts +41 -0
  6. package/dist/src/cognition/filesystem-observer.js +164 -0
  7. package/dist/src/cognition/git-observer.d.ts +1 -0
  8. package/dist/src/cognition/git-observer.js +3 -2
  9. package/dist/src/cognition/journal.d.ts +4 -1
  10. package/dist/src/cognition/journal.js +83 -4
  11. package/dist/src/cognition/migration-v1.js +1 -1
  12. package/dist/src/cognition/orient.js +4 -3
  13. package/dist/src/cognition/policy.d.ts +4 -0
  14. package/dist/src/cognition/policy.js +49 -0
  15. package/dist/src/cognition/projections.js +0 -0
  16. package/dist/src/cognition/runtime.d.ts +20 -1
  17. package/dist/src/cognition/runtime.js +229 -53
  18. package/dist/src/cognition/types.d.ts +16 -0
  19. package/dist/src/cognition/v1-bridge.js +3 -2
  20. package/dist/src/compiler/activity.d.ts +3 -0
  21. package/dist/src/compiler/activity.js +79 -0
  22. package/dist/src/compiler/compile.d.ts +2 -0
  23. package/dist/src/compiler/compile.js +330 -0
  24. package/dist/src/compiler/sources.d.ts +19 -0
  25. package/dist/src/compiler/sources.js +130 -0
  26. package/dist/src/compiler/types.d.ts +187 -0
  27. package/dist/src/compiler/types.js +1 -0
  28. package/dist/src/hooks/claude-code.js +7 -15
  29. package/dist/src/mcp/main.js +2 -0
  30. package/dist/src/mcp/server.js +2 -0
  31. package/dist/src/storage/layout.d.ts +1 -0
  32. package/dist/src/storage/layout.js +3 -0
  33. package/dist/src/version.d.ts +1 -1
  34. package/dist/src/version.js +1 -1
  35. package/package.json +1 -1
  36. package/skills/fluffy-context/SKILL.md +2 -1
@@ -5,6 +5,15 @@ import { resolveProjectRoot } from '../project/project-resolver.js';
5
5
  import { loadManifest } from '../runtime/init.js';
6
6
  import { withLock } from '../storage/lock.js';
7
7
  import { cognitionJournalDirectory, locksDirectory } from '../storage/layout.js';
8
+ const eventTypes = new Set([
9
+ 'runtime.enabled', 'runtime.started', 'runtime.stopped', 'host.session.started', 'host.prompt.submitted',
10
+ 'git.state.observed', 'git.branch.changed', 'git.head.changed', 'workspace.paths.changed',
11
+ 'context.checkpoint.imported', 'context.state.recorded', 'note.recorded', 'note.resolved', 'note.absorbed',
12
+ 'knowledge.proposed', 'knowledge.verified', 'knowledge.deprecated', 'knowledge.rejected',
13
+ 'deadend.proposed', 'deadend.verified', 'deadend.obsoleted', 'deadend.rejected', 'record.used',
14
+ 'projection.advanced', 'projection.failed',
15
+ ]);
16
+ const sourceKinds = new Set(['cli', 'claude-hook', 'mcp', 'git-observer', 'fs-observer', 'migration']);
8
17
  const segmentName = '00000001.ndjson';
9
18
  function canonical(value) {
10
19
  if (value === null || typeof value !== 'object')
@@ -33,6 +42,49 @@ async function journalFiles(projectRoot) {
33
42
  throw error;
34
43
  }
35
44
  }
45
+ function isRecord(value) {
46
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
47
+ }
48
+ function validGitAnchor(value) {
49
+ return isRecord(value)
50
+ && (typeof value.worktreeId === 'string' || value.worktreeId === null)
51
+ && (typeof value.branch === 'string' || value.branch === null)
52
+ && (typeof value.ref === 'string' || value.ref === null)
53
+ && (typeof value.head === 'string' || value.head === null);
54
+ }
55
+ function validIsoDate(value) {
56
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
57
+ }
58
+ function validateEventShape(event, projectId, file, line) {
59
+ if (!isRecord(event)
60
+ || event.schemaVersion !== 2
61
+ || typeof event.eventId !== 'string'
62
+ || event.eventId.length === 0
63
+ || typeof event.type !== 'string'
64
+ || !eventTypes.has(event.type)
65
+ || !validIsoDate(event.occurredAt)
66
+ || !validIsoDate(event.observedAt)
67
+ || event.projectId !== projectId
68
+ || !isRecord(event.source)
69
+ || typeof event.source.kind !== 'string'
70
+ || !sourceKinds.has(event.source.kind)
71
+ || (event.source.instanceId !== undefined && typeof event.source.instanceId !== 'string')
72
+ || (event.correlationId !== undefined && typeof event.correlationId !== 'string')
73
+ || typeof event.idempotencyKey !== 'string'
74
+ || event.idempotencyKey.length === 0
75
+ || !validGitAnchor(event.git)
76
+ || !isRecord(event.privacy)
77
+ || (event.privacy.classification !== 'metadata' && event.privacy.classification !== 'redacted-text')
78
+ || typeof event.privacy.redactionCount !== 'number'
79
+ || !Number.isSafeInteger(event.privacy.redactionCount)
80
+ || event.privacy.redactionCount < 0
81
+ || !isRecord(event.payload)
82
+ || (event.previousHash !== null && typeof event.previousHash !== 'string')
83
+ || typeof event.hash !== 'string'
84
+ || event.hash.length === 0) {
85
+ throw new Error(`invalid journal record schema at ${file}:${line}`);
86
+ }
87
+ }
36
88
  async function recoverPartialTail(projectRoot) {
37
89
  const files = await journalFiles(projectRoot);
38
90
  const file = files.at(-1);
@@ -46,13 +98,15 @@ async function recoverPartialTail(projectRoot) {
46
98
  const tail = content.slice(boundary + 1);
47
99
  try {
48
100
  JSON.parse(tail);
101
+ await appendFile(filePath, '\n', 'utf8');
49
102
  }
50
103
  catch {
51
104
  await truncate(filePath, boundary < 0 ? 0 : boundary + 1);
52
105
  }
53
106
  }
54
- export async function readJournal(startPath) {
107
+ export async function readJournal(startPath, options = {}) {
55
108
  const projectRoot = await resolveProjectRoot(startPath);
109
+ const projectId = (await loadManifest(projectRoot)).projectId;
56
110
  const events = [];
57
111
  let previousHash = null;
58
112
  for (const file of await journalFiles(projectRoot)) {
@@ -67,12 +121,13 @@ export async function readJournal(startPath) {
67
121
  event = JSON.parse(line);
68
122
  }
69
123
  catch (error) {
70
- if (index === lines.length - 1 && !content.endsWith('\n'))
124
+ if (options.allowPartialTail !== false && index === lines.length - 1 && !content.endsWith('\n'))
71
125
  break;
72
126
  throw new Error(`invalid journal record ${file}:${index + 1}: ${error instanceof Error ? error.message : 'invalid JSON'}`);
73
127
  }
128
+ validateEventShape(event, projectId, file, index + 1);
74
129
  const { hash, ...withoutHash } = event;
75
- if (event.schemaVersion !== 2 || !event.eventId || event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
130
+ if (event.previousHash !== previousHash || hash !== eventHash(withoutHash)) {
76
131
  throw new Error(`invalid journal hash chain at ${file}:${index + 1}`);
77
132
  }
78
133
  previousHash = event.hash;
@@ -81,14 +136,38 @@ export async function readJournal(startPath) {
81
136
  }
82
137
  return events;
83
138
  }
139
+ export async function verifyJournal(startPath) {
140
+ return readJournal(startPath, { allowPartialTail: false });
141
+ }
84
142
  export async function appendEvent(startPath, input) {
85
143
  const projectRoot = await resolveProjectRoot(startPath);
86
144
  return withLock(`${locksDirectory(projectRoot)}/cognition-journal.lock`, async () => {
87
145
  await recoverPartialTail(projectRoot);
88
146
  const existing = await readJournal(projectRoot);
89
147
  const duplicate = existing.find((event) => event.idempotencyKey === input.idempotencyKey);
90
- if (duplicate)
148
+ if (duplicate) {
149
+ const candidate = {
150
+ type: input.type,
151
+ source: input.source ?? { kind: 'cli' },
152
+ correlationId: input.correlationId,
153
+ idempotencyKey: input.idempotencyKey,
154
+ git: input.git ?? { worktreeId: null, branch: null, ref: null, head: null },
155
+ privacy: input.privacy ?? { classification: 'metadata', redactionCount: 0 },
156
+ payload: input.payload,
157
+ };
158
+ const existingSemantic = {
159
+ type: duplicate.type,
160
+ source: duplicate.source,
161
+ correlationId: duplicate.correlationId,
162
+ idempotencyKey: duplicate.idempotencyKey,
163
+ git: duplicate.git,
164
+ privacy: duplicate.privacy,
165
+ payload: duplicate.payload,
166
+ };
167
+ if (canonical(candidate) !== canonical(existingSemantic))
168
+ throw new Error(`idempotency key conflict: ${input.idempotencyKey}`);
91
169
  return { event: duplicate, appended: false };
170
+ }
92
171
  const now = new Date().toISOString();
93
172
  const eventWithoutHash = {
94
173
  eventId: input.eventId ?? eventId(),
@@ -105,7 +105,7 @@ async function sourceRecords(projectRoot) {
105
105
  async function writeV2Metadata(projectRoot) {
106
106
  const v1 = await loadManifest(projectRoot);
107
107
  const manifest = { schemaVersion: 2, layoutVersion: COGNITION_LAYOUT_VERSION, projectId: v1.projectId, createdAt: new Date().toISOString(), policyVersion: 1 };
108
- const policy = { schemaVersion: 2, enabled: false, allowHookStartup: false, inactivityTimeoutMs: 300_000, promptIntentMaxChars: 500, filesystemEventsEnabled: false, retentionMaxBytes: 64 * 1024 * 1024 };
108
+ const policy = { schemaVersion: 2, enabled: true, autoStart: true, allowHookStartup: true, inactivityTimeoutMs: 300_000, promptIntentMaxChars: 500, filesystemEventsEnabled: false, retentionMaxBytes: 64 * 1024 * 1024 };
109
109
  await Promise.all([atomicWriteJson(cognitionManifestPath(projectRoot), manifest), atomicWriteJson(cognitionPolicyPath(projectRoot), policy)]);
110
110
  }
111
111
  async function quarantineReadiness(projectRoot, readiness) {
@@ -1,11 +1,12 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { readGitWorktreeState } from '../git/git-adapter.js';
3
- import { cognitionManifestPath, cognitionPolicyPath } from '../storage/layout.js';
3
+ import { cognitionManifestPath } from '../storage/layout.js';
4
4
  import { readJson } from '../storage/json-store.js';
5
5
  import { loadManifest } from '../runtime/init.js';
6
6
  import { readJournal } from './journal.js';
7
7
  import { projectEvents, workspaceKey } from './projections.js';
8
8
  import { buildRetrievalProjection } from './retrieval.js';
9
+ import { loadRuntimePolicy } from './policy.js';
9
10
  import { v1Synced } from './v1-bridge.js';
10
11
  function contextReference(value) {
11
12
  const boundary = value.lastIndexOf(':');
@@ -48,7 +49,7 @@ export async function selectV2Orientation(projectRoot, contextId) {
48
49
  const [manifest, cognition, policy] = await Promise.all([
49
50
  loadManifest(projectRoot),
50
51
  readJson(cognitionManifestPath(projectRoot)),
51
- readJson(cognitionPolicyPath(projectRoot)),
52
+ loadRuntimePolicy(projectRoot),
52
53
  ]);
53
54
  if (cognition.schemaVersion !== 2 || cognition.projectId !== manifest.projectId || !policy.enabled || !await v1Synced(projectRoot))
54
55
  return null;
@@ -86,7 +87,7 @@ export async function selectV2Orientation(projectRoot, contextId) {
86
87
  export async function v2Enabled(projectRoot) {
87
88
  try {
88
89
  await access(cognitionManifestPath(projectRoot));
89
- return (await readJson(cognitionPolicyPath(projectRoot))).enabled;
90
+ return (await loadRuntimePolicy(projectRoot)).enabled;
90
91
  }
91
92
  catch {
92
93
  return false;
@@ -0,0 +1,4 @@
1
+ import type { CognitionRuntimePolicy } from './types.js';
2
+ export declare function normalizeRuntimePolicy(value: Partial<CognitionRuntimePolicy>): CognitionRuntimePolicy;
3
+ export declare function loadRuntimePolicy(projectRoot: string): Promise<CognitionRuntimePolicy>;
4
+ export declare function writeRuntimePolicy(projectRoot: string, policy: CognitionRuntimePolicy): Promise<void>;
@@ -0,0 +1,49 @@
1
+ import { atomicWriteJson } from '../storage/atomic-write.js';
2
+ import { cognitionPolicyPath } from '../storage/layout.js';
3
+ import { readJson } from '../storage/json-store.js';
4
+ const defaults = {
5
+ schemaVersion: 2,
6
+ enabled: true,
7
+ autoStart: true,
8
+ allowHookStartup: true,
9
+ inactivityTimeoutMs: 300_000,
10
+ promptIntentMaxChars: 500,
11
+ filesystemEventsEnabled: false,
12
+ retentionMaxBytes: 64 * 1024 * 1024,
13
+ };
14
+ function booleanValue(value, fallback, field) {
15
+ if (value === undefined)
16
+ return fallback;
17
+ if (typeof value !== 'boolean')
18
+ throw new Error(`runtime policy field ${field} must be boolean`);
19
+ return value;
20
+ }
21
+ function nonNegativeInteger(value, fallback, field) {
22
+ if (value === undefined)
23
+ return fallback;
24
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
25
+ throw new Error(`runtime policy field ${field} must be a non-negative integer`);
26
+ return value;
27
+ }
28
+ export function normalizeRuntimePolicy(value) {
29
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
30
+ throw new Error('runtime policy must be an object');
31
+ if (value.schemaVersion !== undefined && value.schemaVersion !== 2)
32
+ throw new Error('unsupported runtime policy schema');
33
+ return {
34
+ ...defaults,
35
+ enabled: booleanValue(value.enabled, defaults.enabled, 'enabled'),
36
+ autoStart: booleanValue(value.autoStart, defaults.autoStart, 'autoStart'),
37
+ allowHookStartup: booleanValue(value.allowHookStartup, defaults.allowHookStartup, 'allowHookStartup'),
38
+ inactivityTimeoutMs: nonNegativeInteger(value.inactivityTimeoutMs, defaults.inactivityTimeoutMs, 'inactivityTimeoutMs'),
39
+ promptIntentMaxChars: nonNegativeInteger(value.promptIntentMaxChars, defaults.promptIntentMaxChars, 'promptIntentMaxChars'),
40
+ filesystemEventsEnabled: booleanValue(value.filesystemEventsEnabled, defaults.filesystemEventsEnabled, 'filesystemEventsEnabled'),
41
+ retentionMaxBytes: nonNegativeInteger(value.retentionMaxBytes, defaults.retentionMaxBytes, 'retentionMaxBytes'),
42
+ };
43
+ }
44
+ export async function loadRuntimePolicy(projectRoot) {
45
+ return normalizeRuntimePolicy(await readJson(cognitionPolicyPath(projectRoot)));
46
+ }
47
+ export async function writeRuntimePolicy(projectRoot, policy) {
48
+ await atomicWriteJson(cognitionPolicyPath(projectRoot), normalizeRuntimePolicy(policy));
49
+ }
Binary file
@@ -3,10 +3,29 @@ export interface RuntimeServer {
3
3
  lease: RuntimeLease;
4
4
  close(reason?: string): Promise<void>;
5
5
  }
6
- export declare function serveRuntime(startPath?: string): Promise<RuntimeServer>;
6
+ interface RuntimeServeOptions {
7
+ inactivityTimeoutMs?: number;
8
+ heartbeatIntervalMs?: number;
9
+ pollIntervalMs?: number;
10
+ }
11
+ export interface EnsureRuntimeOptions {
12
+ source?: 'claude-hook' | 'mcp' | 'cli';
13
+ timeoutMs?: number;
14
+ }
15
+ export interface EnsureRuntimeResult {
16
+ projectRoot: string;
17
+ started: boolean;
18
+ running: boolean;
19
+ skipped: 'disabled' | 'not-migrated' | 'already-running' | 'startup-in-progress' | 'startup-failed' | 'unavailable' | null;
20
+ }
21
+ export declare function serveRuntime(startPath?: string, options?: RuntimeServeOptions): Promise<RuntimeServer>;
7
22
  export declare function runtimeStatus(startPath?: string): Promise<RuntimeStatus>;
8
23
  export declare function enableRuntime(startPath?: string): Promise<RuntimeStatus>;
9
24
  export declare function disableRuntime(startPath?: string): Promise<RuntimeStatus>;
25
+ export declare function setFilesystemEvents(startPath: string | undefined, enabled: boolean): Promise<RuntimeStatus>;
10
26
  export declare function startRuntime(startPath?: string): Promise<RuntimeStatus>;
27
+ export declare function ensureRuntimeForSession(startPath?: string, options?: EnsureRuntimeOptions): Promise<EnsureRuntimeResult>;
11
28
  export declare function stopRuntime(startPath?: string): Promise<RuntimeStatus>;
12
29
  export declare function observeRuntime(startPath?: string): Promise<boolean>;
30
+ export declare function touchRuntime(startPath?: string): Promise<boolean>;
31
+ export {};
@@ -9,8 +9,11 @@ import { loadManifest } from '../runtime/init.js';
9
9
  import { atomicWriteJson } from '../storage/atomic-write.js';
10
10
  import { readJson } from '../storage/json-store.js';
11
11
  import { withLock } from '../storage/lock.js';
12
- import { cognitionManifestPath, cognitionPolicyPath, cognitionRuntimeLeasePath, cognitionRuntimeSecretPath, locksDirectory } from '../storage/layout.js';
12
+ import { cognitionManifestPath, cognitionRuntimeLeasePath, cognitionRuntimeSecretPath, cognitionRuntimeStartupPath, locksDirectory } from '../storage/layout.js';
13
13
  import { appendEvent } from './journal.js';
14
+ import { rebuildProjections } from './projections.js';
15
+ import { loadRuntimePolicy, writeRuntimePolicy } from './policy.js';
16
+ import { FilesystemObserver } from './filesystem-observer.js';
14
17
  import { GitObserver } from './git-observer.js';
15
18
  import { reconcileV1State } from './migration-v1.js';
16
19
  import { v1Synced } from './v1-bridge.js';
@@ -19,6 +22,8 @@ const protocolVersion = 1;
19
22
  const maxFrameBytes = 16 * 1024;
20
23
  const pollIntervalMs = 2_000;
21
24
  const cliPath = fileURLToPath(new URL('../cli/main.js', import.meta.url));
25
+ const startupPromises = new Map();
26
+ const startupMarkerTimeoutMs = 10_000;
22
27
  function createInstanceId() {
23
28
  return crypto.randomBytes(12).toString('hex');
24
29
  }
@@ -44,11 +49,21 @@ async function exists(filePath) {
44
49
  }
45
50
  }
46
51
  async function loadPolicy(projectRoot) {
47
- return readJson(cognitionPolicyPath(projectRoot));
52
+ return loadRuntimePolicy(projectRoot);
48
53
  }
49
54
  async function loadLease(projectRoot) {
50
55
  return readJson(cognitionRuntimeLeasePath(projectRoot)).catch(() => null);
51
56
  }
57
+ async function loadStartupMarker(projectRoot) {
58
+ return readJson(cognitionRuntimeStartupPath(projectRoot)).catch(() => null);
59
+ }
60
+ async function removeOwnedRuntimeFiles(projectRoot, instanceId) {
61
+ const current = await loadLease(projectRoot);
62
+ if (current?.instanceId !== instanceId)
63
+ return;
64
+ await rm(cognitionRuntimeLeasePath(projectRoot), { force: true });
65
+ await rm(cognitionRuntimeSecretPath(projectRoot), { force: true });
66
+ }
52
67
  async function request(projectRoot, lease, method) {
53
68
  let secret;
54
69
  try {
@@ -92,23 +107,52 @@ async function liveLease(projectRoot, lease) {
92
107
  async function writeLease(projectRoot, lease) {
93
108
  await atomicWriteJson(cognitionRuntimeLeasePath(projectRoot), lease);
94
109
  }
95
- async function closeServer(projectRoot, server, observer, heartbeat, poller, inactivity, lease, reason) {
96
- clearInterval(heartbeat);
97
- clearInterval(poller);
98
- clearTimeout(inactivity);
99
- await observer.drain();
100
- await new Promise((resolve) => server.close(() => resolve()));
101
- await new Promise((resolve) => setImmediate(resolve));
102
- await rm(cognitionRuntimeLeasePath(projectRoot), { force: true });
103
- await rm(cognitionRuntimeSecretPath(projectRoot), { force: true });
104
- await appendEvent(projectRoot, {
105
- type: 'runtime.stopped',
106
- source: { kind: 'cli', instanceId: lease.instanceId },
107
- idempotencyKey: `runtime:stopped:${lease.instanceId}`,
108
- payload: { reason },
109
- });
110
+ async function closeServer(projectRoot, server, observer, filesystem, heartbeat, poller, inactivity, background, leaseWrite, lease, reason) {
111
+ if (heartbeat)
112
+ clearInterval(heartbeat);
113
+ if (poller)
114
+ clearInterval(poller);
115
+ if (inactivity)
116
+ clearTimeout(inactivity);
117
+ let failure;
118
+ try {
119
+ filesystem.stop();
120
+ await filesystem.flush();
121
+ await observer.drain();
122
+ await filesystem.drain();
123
+ await background;
124
+ await rebuildProjections(projectRoot);
125
+ await leaseWrite;
126
+ }
127
+ catch (error) {
128
+ failure = error;
129
+ }
130
+ finally {
131
+ await new Promise((resolve) => {
132
+ if (!server.listening) {
133
+ resolve();
134
+ return;
135
+ }
136
+ server.close(() => resolve());
137
+ });
138
+ await new Promise((resolve) => setImmediate(resolve));
139
+ await removeOwnedRuntimeFiles(projectRoot, lease.instanceId);
140
+ }
141
+ try {
142
+ await appendEvent(projectRoot, {
143
+ type: 'runtime.stopped',
144
+ source: { kind: 'cli', instanceId: lease.instanceId },
145
+ idempotencyKey: `runtime:stopped:${lease.instanceId}`,
146
+ payload: { reason },
147
+ });
148
+ }
149
+ catch (error) {
150
+ failure ??= error;
151
+ }
152
+ if (failure)
153
+ throw failure;
110
154
  }
111
- function attachProtocol(projectRoot, server, observer, secret, lease, touch, close) {
155
+ function attachProtocol(server, secret, lease, touch, observe, close) {
112
156
  server.on('connection', (socket) => {
113
157
  socket.setEncoding('utf8');
114
158
  let buffer = '';
@@ -142,7 +186,7 @@ function attachProtocol(projectRoot, server, observer, secret, lease, touch, clo
142
186
  }
143
187
  if (request.method === 'observe') {
144
188
  try {
145
- const result = await observer.observeNow();
189
+ const result = await observe();
146
190
  socket.end(`${JSON.stringify({ ok: true, result: { changed: result.changed } })}\n`);
147
191
  }
148
192
  catch {
@@ -159,11 +203,14 @@ function attachProtocol(projectRoot, server, observer, secret, lease, touch, clo
159
203
  });
160
204
  });
161
205
  }
162
- export async function serveRuntime(startPath) {
206
+ export async function serveRuntime(startPath, options = {}) {
163
207
  const projectRoot = await resolveProjectRoot(startPath);
164
208
  const currentPolicy = await loadPolicy(projectRoot);
165
209
  if (!currentPolicy.enabled)
166
210
  throw new Error('runtime is disabled; run ctx runtime enable first');
211
+ const inactivityTimeoutMs = options.inactivityTimeoutMs ?? currentPolicy.inactivityTimeoutMs;
212
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? 5_000;
213
+ const runtimePollIntervalMs = options.pollIntervalMs ?? pollIntervalMs;
167
214
  const existing = await liveLease(projectRoot);
168
215
  if (existing)
169
216
  throw new Error('runtime is already running');
@@ -182,21 +229,55 @@ export async function serveRuntime(startPath) {
182
229
  const lease = { schemaVersion: 2, protocolVersion, projectId: manifest.projectId, instanceId: createInstanceId(), pid: process.pid, host, port: address.port, startedAt: now, heartbeatAt: now };
183
230
  const secret = createToken();
184
231
  const observer = new GitObserver(projectRoot);
185
- let closed = false;
186
- let heartbeat;
187
- let poller;
188
- let inactivity;
232
+ let filesystem;
233
+ let heartbeat = null;
234
+ let poller = null;
235
+ let inactivity = null;
236
+ let background = Promise.resolve();
237
+ let leaseWrite = Promise.resolve();
238
+ let filesystemEventPending = false;
239
+ let closing = null;
240
+ const enqueueObservation = (operation) => {
241
+ const next = background.then(operation, operation);
242
+ background = next.then(() => undefined, () => undefined);
243
+ return next;
244
+ };
189
245
  const close = async (reason) => {
190
- if (closed)
191
- return;
192
- closed = true;
193
- await closeServer(projectRoot, server, observer, heartbeat, poller, inactivity, lease, reason);
246
+ if (closing)
247
+ return closing;
248
+ closing = closeServer(projectRoot, server, observer, filesystem, heartbeat, poller, inactivity, background, leaseWrite, lease, reason);
249
+ return closing;
194
250
  };
195
251
  const touch = () => {
196
- clearTimeout(inactivity);
197
- inactivity = setTimeout(() => { void close('inactivity'); }, currentPolicy.inactivityTimeoutMs);
252
+ if (inactivity)
253
+ clearTimeout(inactivity);
254
+ inactivity = setTimeout(() => { void close('inactivity').catch(() => undefined); }, inactivityTimeoutMs);
198
255
  };
199
- attachProtocol(projectRoot, server, observer, secret, lease, touch, close);
256
+ const observe = () => enqueueObservation(async () => {
257
+ const [gitResult, filesystemResult] = await Promise.all([
258
+ observer.observeNow(),
259
+ filesystem.observeNow(),
260
+ ]);
261
+ const synced = await v1Synced(projectRoot);
262
+ if (!synced)
263
+ await reconcileV1State(projectRoot);
264
+ if (gitResult.changed || filesystemResult.changed || filesystemEventPending || !synced) {
265
+ await rebuildProjections(projectRoot);
266
+ filesystemEventPending = false;
267
+ }
268
+ const changed = gitResult.changed || filesystemResult.changed || filesystemEventPending || !synced;
269
+ if (changed)
270
+ touch();
271
+ return { changed };
272
+ });
273
+ filesystem = new FilesystemObserver(projectRoot, {
274
+ anchor: () => ({ worktreeId: observer.current?.worktreeId ?? null, branch: observer.current?.branch ?? null, ref: observer.current?.ref ?? null, head: observer.current?.commit ?? null }),
275
+ onChanged: () => {
276
+ filesystemEventPending = true;
277
+ touch();
278
+ },
279
+ });
280
+ attachProtocol(server, secret, lease, touch, observe, close);
200
281
  try {
201
282
  await withLock(`${locksDirectory(projectRoot)}/cognition-runtime-start.lock`, async () => {
202
283
  const live = await liveLease(projectRoot);
@@ -213,16 +294,18 @@ export async function serveRuntime(startPath) {
213
294
  }
214
295
  heartbeat = setInterval(() => {
215
296
  lease.heartbeatAt = new Date().toISOString();
216
- void writeLease(projectRoot, lease);
217
- }, 5_000);
297
+ leaseWrite = leaseWrite.then(() => writeLease(projectRoot, lease)).catch(() => undefined);
298
+ }, heartbeatIntervalMs);
218
299
  poller = setInterval(() => {
219
- void observer.observeNow();
220
- void v1Synced(projectRoot).then((synced) => { if (!synced)
221
- return reconcileV1State(projectRoot); });
222
- }, pollIntervalMs);
223
- touch();
300
+ void observe().catch(() => undefined);
301
+ }, runtimePollIntervalMs);
224
302
  await appendEvent(projectRoot, { type: 'runtime.started', source: { kind: 'cli', instanceId: lease.instanceId }, idempotencyKey: `runtime:started:${lease.instanceId}`, payload: {} });
225
- await observer.observeNow();
303
+ if (currentPolicy.filesystemEventsEnabled)
304
+ filesystem.start();
305
+ await observe();
306
+ await writeLease(projectRoot, lease);
307
+ await rm(cognitionRuntimeStartupPath(projectRoot), { force: true });
308
+ touch();
226
309
  return { lease, close };
227
310
  }
228
311
  export async function runtimeStatus(startPath) {
@@ -240,8 +323,8 @@ export async function enableRuntime(startPath) {
240
323
  if (!await exists(cognitionManifestPath(projectRoot)))
241
324
  throw new Error('v2 migration is required before enabling the runtime');
242
325
  const current = await loadPolicy(projectRoot);
243
- if (!current.enabled) {
244
- await atomicWriteJson(cognitionPolicyPath(projectRoot), { ...current, enabled: true });
326
+ if (!current.enabled || !current.autoStart) {
327
+ await writeRuntimePolicy(projectRoot, { ...current, enabled: true, autoStart: true });
245
328
  await appendEvent(projectRoot, { type: 'runtime.enabled', source: { kind: 'cli' }, idempotencyKey: 'runtime:enabled', payload: {} });
246
329
  }
247
330
  return runtimeStatus(projectRoot);
@@ -252,25 +335,106 @@ export async function disableRuntime(startPath) {
252
335
  throw new Error('v2 migration is required before disabling the runtime');
253
336
  await stopRuntime(projectRoot);
254
337
  const current = await loadPolicy(projectRoot);
255
- await atomicWriteJson(cognitionPolicyPath(projectRoot), { ...current, enabled: false });
338
+ await writeRuntimePolicy(projectRoot, { ...current, enabled: false, autoStart: false });
256
339
  return runtimeStatus(projectRoot);
257
340
  }
341
+ export async function setFilesystemEvents(startPath, enabled) {
342
+ const projectRoot = await resolveProjectRoot(startPath);
343
+ if (!await exists(cognitionManifestPath(projectRoot)))
344
+ throw new Error('v2 migration is required before configuring filesystem events');
345
+ const wasRunning = (await runtimeStatus(projectRoot)).running;
346
+ if (wasRunning)
347
+ await stopRuntime(projectRoot);
348
+ const current = await loadPolicy(projectRoot);
349
+ await writeRuntimePolicy(projectRoot, { ...current, filesystemEventsEnabled: enabled });
350
+ if (wasRunning)
351
+ return startRuntime(projectRoot);
352
+ return runtimeStatus(projectRoot);
353
+ }
354
+ async function waitForRuntime(projectRoot, timeoutMs) {
355
+ const deadline = Date.now() + timeoutMs;
356
+ while (Date.now() < deadline) {
357
+ const status = await runtimeStatus(projectRoot);
358
+ if (status.running && !await loadStartupMarker(projectRoot))
359
+ return status;
360
+ await new Promise((resolve) => setTimeout(resolve, 50));
361
+ }
362
+ return null;
363
+ }
364
+ async function startRuntimeCoordinated(projectRoot, timeoutMs) {
365
+ const existing = await liveLease(projectRoot);
366
+ if (existing)
367
+ return runtimeStatus(projectRoot);
368
+ const attemptId = createInstanceId();
369
+ let owner = false;
370
+ try {
371
+ await withLock(`${locksDirectory(projectRoot)}/cognition-runtime-start.lock`, async () => {
372
+ const live = await liveLease(projectRoot);
373
+ if (live)
374
+ return;
375
+ const marker = await loadStartupMarker(projectRoot);
376
+ if (marker && Date.now() - Date.parse(marker.createdAt) <= startupMarkerTimeoutMs)
377
+ return;
378
+ await atomicWriteJson(cognitionRuntimeStartupPath(projectRoot), { attemptId, pid: process.pid, createdAt: new Date().toISOString() });
379
+ owner = true;
380
+ });
381
+ }
382
+ catch {
383
+ // Another process owns the startup lock; it will publish the lease or the marker will expire.
384
+ }
385
+ if (owner) {
386
+ const child = spawn(process.execPath, [cliPath, 'runtime', 'serve', '--path', projectRoot], { detached: true, stdio: 'ignore', windowsHide: true });
387
+ child.unref();
388
+ }
389
+ const status = await waitForRuntime(projectRoot, timeoutMs);
390
+ const marker = await loadStartupMarker(projectRoot);
391
+ if (marker?.attemptId === attemptId)
392
+ await rm(cognitionRuntimeStartupPath(projectRoot), { force: true });
393
+ if (status)
394
+ return status;
395
+ throw new Error('runtime failed to start');
396
+ }
258
397
  export async function startRuntime(startPath) {
259
398
  const projectRoot = await resolveProjectRoot(startPath);
260
399
  const current = await loadPolicy(projectRoot);
261
400
  if (!current.enabled)
262
401
  throw new Error('runtime is disabled; run ctx runtime enable first');
402
+ const pending = startupPromises.get(projectRoot);
403
+ if (pending)
404
+ return pending;
405
+ const operation = startRuntimeCoordinated(projectRoot, 1_500).finally(() => startupPromises.delete(projectRoot));
406
+ startupPromises.set(projectRoot, operation);
407
+ return operation;
408
+ }
409
+ export async function ensureRuntimeForSession(startPath, options = {}) {
410
+ const projectRoot = await resolveProjectRoot(startPath);
411
+ if (!await exists(cognitionManifestPath(projectRoot)))
412
+ return { projectRoot, started: false, running: false, skipped: 'not-migrated' };
413
+ const current = await loadPolicy(projectRoot);
414
+ if (!current.enabled || !current.autoStart || (options.source === 'claude-hook' && !current.allowHookStartup))
415
+ return { projectRoot, started: false, running: false, skipped: 'disabled' };
263
416
  if (await liveLease(projectRoot))
264
- return runtimeStatus(projectRoot);
265
- const child = spawn(process.execPath, [cliPath, 'runtime', 'serve', '--path', projectRoot], { detached: true, stdio: 'ignore', windowsHide: true });
266
- child.unref();
267
- for (let attempt = 0; attempt < 20; attempt += 1) {
268
- await new Promise((resolve) => setTimeout(resolve, 50));
269
- const status = await runtimeStatus(projectRoot);
270
- if (status.running)
271
- return status;
417
+ return { projectRoot, started: false, running: true, skipped: 'already-running' };
418
+ const pending = startupPromises.get(projectRoot);
419
+ if (pending) {
420
+ try {
421
+ const status = await pending;
422
+ return { projectRoot, started: false, running: status.running, skipped: 'startup-in-progress' };
423
+ }
424
+ catch {
425
+ return { projectRoot, started: false, running: false, skipped: 'unavailable' };
426
+ }
427
+ }
428
+ const operation = startRuntimeCoordinated(projectRoot, options.timeoutMs ?? 1_000)
429
+ .finally(() => startupPromises.delete(projectRoot));
430
+ startupPromises.set(projectRoot, operation);
431
+ try {
432
+ const status = await operation;
433
+ return { projectRoot, started: true, running: status.running, skipped: null };
434
+ }
435
+ catch {
436
+ return { projectRoot, started: false, running: false, skipped: 'unavailable' };
272
437
  }
273
- throw new Error('runtime failed to start');
274
438
  }
275
439
  export async function stopRuntime(startPath) {
276
440
  const projectRoot = await resolveProjectRoot(startPath);
@@ -278,10 +442,10 @@ export async function stopRuntime(startPath) {
278
442
  if (!lease)
279
443
  return runtimeStatus(projectRoot);
280
444
  await request(projectRoot, lease, 'stop');
281
- for (let attempt = 0; attempt < 20; attempt += 1) {
445
+ for (let attempt = 0; attempt < 40; attempt += 1) {
282
446
  await new Promise((resolve) => setTimeout(resolve, 50));
283
447
  const status = await runtimeStatus(projectRoot);
284
- if (!status.running)
448
+ if (!status.running && !status.lease)
285
449
  return status;
286
450
  }
287
451
  throw new Error('runtime failed to stop');
@@ -293,3 +457,15 @@ export async function observeRuntime(startPath) {
293
457
  return false;
294
458
  return Boolean((await request(projectRoot, lease, 'observe'))?.ok);
295
459
  }
460
+ export async function touchRuntime(startPath) {
461
+ try {
462
+ const projectRoot = await resolveProjectRoot(startPath);
463
+ const lease = await loadLease(projectRoot);
464
+ if (!lease)
465
+ return false;
466
+ return Boolean((await request(projectRoot, lease, 'ping'))?.ok);
467
+ }
468
+ catch {
469
+ return false;
470
+ }
471
+ }