klypix-mcp 1.45.1 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/klypix-conformance.mjs +102 -0
- package/package.json +3 -2
- package/src/agent-presence.mjs +81 -0
- package/src/klypix-format.mjs +51 -7
- package/src/presence-relay.mjs +250 -0
|
@@ -160,6 +160,103 @@ try {
|
|
|
160
160
|
a.client.callTool({ name: 'brain_sync', arguments: { phase: 'complete' } }),
|
|
161
161
|
b.client.callTool({ name: 'brain_sync', arguments: { phase: 'complete' } }),
|
|
162
162
|
]);
|
|
163
|
+
|
|
164
|
+
// ── Cross-PC presence: simulated two-machine scenario ─────────────────────
|
|
165
|
+
// Two isolated registries (one per "machine") + a mock channel around the
|
|
166
|
+
// pure transport seam (src/presence-relay.mjs relayOutbound/relayInbound —
|
|
167
|
+
// the exact functions the desktop relay wraps its Realtime channel with).
|
|
168
|
+
// Proves: peer visibility across machines, overlap warning on the canonical
|
|
169
|
+
// file key, message delivered once under double-delivery, clean degradation
|
|
170
|
+
// with the channel dead, and the consent gate at the seam.
|
|
171
|
+
{
|
|
172
|
+
const [{ relayOutbound, relayInbound, PRESENCE_CONSENT_VERSION, PRESENCE_CONSENT_PURPOSE, PRESENCE_CONSENT_SCOPE },
|
|
173
|
+
{ upsertSession, upsertRemoteSessions, listActiveSessions, postPresenceMessage, receiveMessages },
|
|
174
|
+
{ findPresenceConflicts }] = await Promise.all([
|
|
175
|
+
import('../src/presence-relay.mjs'),
|
|
176
|
+
import('../src/agent-presence.mjs'),
|
|
177
|
+
import('../src/mcp-presence.mjs'),
|
|
178
|
+
]);
|
|
179
|
+
const GRANT = {
|
|
180
|
+
version: PRESENCE_CONSENT_VERSION, decision: 'granted', decidedAt: new Date().toISOString(),
|
|
181
|
+
purpose: PRESENCE_CONSENT_PURPOSE, scope: PRESENCE_CONSENT_SCOPE,
|
|
182
|
+
};
|
|
183
|
+
const xpcRoot = path.join(tempRoot, 'xpc');
|
|
184
|
+
const homeA = path.join(xpcRoot, 'homeA');
|
|
185
|
+
const homeB = path.join(xpcRoot, 'homeB');
|
|
186
|
+
const repoA = path.join(xpcRoot, 'machineA', 'repo');
|
|
187
|
+
const repoB = path.join(xpcRoot, 'machineB', 'repo');
|
|
188
|
+
for (const dir of [homeA, homeB, repoA, repoB]) fs.mkdirSync(dir, { recursive: true });
|
|
189
|
+
const brainA = path.join(repoA, 'brain.klypix');
|
|
190
|
+
const brainB = path.join(repoB, 'brain.klypix');
|
|
191
|
+
fs.writeFileSync(brainA, 'xpc-fixture');
|
|
192
|
+
fs.writeFileSync(brainB, 'xpc-fixture');
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
|
|
195
|
+
upsertSession({
|
|
196
|
+
brainPath: brainA, home: homeA, now, id: 'xpc-dev-a', client: 'claude-code', branch: 'main',
|
|
197
|
+
intent: 'edit the shared component', files: [path.join(repoA, 'src', 'Shared.tsx')],
|
|
198
|
+
});
|
|
199
|
+
upsertSession({
|
|
200
|
+
brainPath: brainB, home: homeB, now, id: 'xpc-dev-b', client: 'codex', branch: 'main',
|
|
201
|
+
intent: 'restyle the shared component', files: ['src/Shared.tsx'],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Consent gate FIRST: with no record, the seam must emit nothing.
|
|
205
|
+
let framesSent = 0;
|
|
206
|
+
const gated = relayOutbound({
|
|
207
|
+
sessions: listActiveSessions({ brainPath: brainA, home: homeA, now }),
|
|
208
|
+
consent: null, machineId: 'xpc-mach-a', root: repoA, now, send: () => { framesSent++; },
|
|
209
|
+
});
|
|
210
|
+
checks.crossMachineConsentGate = framesSent === 0 && gated.reason === 'no-consent';
|
|
211
|
+
|
|
212
|
+
// Live channel: A's session and message reach B exactly once.
|
|
213
|
+
const wire = [];
|
|
214
|
+
relayOutbound({
|
|
215
|
+
sessions: listActiveSessions({ brainPath: brainA, home: homeA, now }),
|
|
216
|
+
messages: (() => {
|
|
217
|
+
postPresenceMessage({ brainPath: brainA, from: 'xpc-dev-a', text: 'starting on Shared.tsx now', home: homeA, now });
|
|
218
|
+
try { return JSON.parse(fs.readFileSync(path.join(homeA, '.claude', 'project-brain', 'sessions', fs.readdirSync(path.join(homeA, '.claude', 'project-brain', 'sessions')).find((f) => f.endsWith('.json'))), 'utf8')).messages || []; }
|
|
219
|
+
catch { return []; }
|
|
220
|
+
})(),
|
|
221
|
+
consent: GRANT, machineId: 'xpc-mach-a', hostLabel: 'MACHINE-A', root: repoA, now,
|
|
222
|
+
send: (frame) => wire.push(frame),
|
|
223
|
+
});
|
|
224
|
+
const deliverAll = (stampNow) => {
|
|
225
|
+
const rows = [];
|
|
226
|
+
for (const frame of wire) { // double-delivery: every frame arrives twice (at-least-once transport)
|
|
227
|
+
for (let i = 0; i < 2; i++) {
|
|
228
|
+
const inbound = relayInbound(frame, { consent: GRANT, machineId: 'xpc-mach-b', now: stampNow });
|
|
229
|
+
if (inbound?.type === 'presence') rows.push(inbound.row);
|
|
230
|
+
if (inbound?.type === 'message') {
|
|
231
|
+
postPresenceMessage({
|
|
232
|
+
brainPath: brainB, from: inbound.message.from, to: inbound.message.to,
|
|
233
|
+
text: inbound.message.text, dedupeKey: inbound.message.dedupeKey, home: homeB, now: stampNow,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (rows.length) upsertRemoteSessions({ brainPath: brainB, rows, machineId: 'xpc-mach-b', home: homeB, now: stampNow });
|
|
239
|
+
};
|
|
240
|
+
deliverAll(now + 500);
|
|
241
|
+
|
|
242
|
+
const bSessions = listActiveSessions({ brainPath: brainB, home: homeB, now: now + 500 });
|
|
243
|
+
const remote = bSessions.find((session) => session.id === 'xpc-dev-a');
|
|
244
|
+
checks.crossMachinePeerVisibility = !!remote && remote.via === 'cloud' && remote.host === 'MACHINE-A';
|
|
245
|
+
const overlaps = findPresenceConflicts(bSessions, 'xpc-dev-b', { projectRoot: repoB });
|
|
246
|
+
checks.crossMachineOverlapWarning = overlaps.length === 1
|
|
247
|
+
&& overlaps[0].id === 'xpc-dev-a'
|
|
248
|
+
&& overlaps[0].files.some((file) => file.toLowerCase().includes('src/shared.tsx'));
|
|
249
|
+
const delivered = receiveMessages({ brainPath: brainB, sessionId: 'xpc-dev-b', home: homeB, now: now + 600 });
|
|
250
|
+
checks.crossMachineMessageOnce = delivered.filter((message) => message.text.includes('Shared.tsx')).length === 1;
|
|
251
|
+
|
|
252
|
+
// Dead channel: outbound reports without throwing; local presence intact.
|
|
253
|
+
const dead = relayOutbound({
|
|
254
|
+
sessions: listActiveSessions({ brainPath: brainA, home: homeA, now: now + 700 }),
|
|
255
|
+
consent: GRANT, machineId: 'xpc-mach-a', root: repoA, now: now + 700, send: undefined,
|
|
256
|
+
});
|
|
257
|
+
checks.crossMachineOfflineDegradation = dead.sent === 0 && dead.reason === 'no-channel'
|
|
258
|
+
&& listActiveSessions({ brainPath: brainA, home: homeA, now: now + 700 }).some((session) => session.id === 'xpc-dev-a');
|
|
259
|
+
}
|
|
163
260
|
} catch (error) {
|
|
164
261
|
checks.runtime = false;
|
|
165
262
|
checks.error = error?.message || String(error);
|
|
@@ -176,6 +273,11 @@ const required = [
|
|
|
176
273
|
'alertQueued',
|
|
177
274
|
'proactiveLogging',
|
|
178
275
|
'guaranteedInBandDelivery',
|
|
276
|
+
'crossMachineConsentGate',
|
|
277
|
+
'crossMachinePeerVisibility',
|
|
278
|
+
'crossMachineOverlapWarning',
|
|
279
|
+
'crossMachineMessageOnce',
|
|
280
|
+
'crossMachineOfflineDegradation',
|
|
179
281
|
];
|
|
180
282
|
const ok = required.every((name) => checks[name] === true);
|
|
181
283
|
const result = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.0",
|
|
4
4
|
"description": "Shared project brain and MCP coordination server for multi-agent coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"./core": "./src/klypix-core.mjs",
|
|
48
48
|
"./presence": "./src/agent-presence.mjs",
|
|
49
49
|
"./mcp-presence": "./src/mcp-presence.mjs",
|
|
50
|
+
"./presence-relay": "./src/presence-relay.mjs",
|
|
50
51
|
"./supervisor": "./src/mcp-supervisor.mjs",
|
|
51
52
|
"./auto-update": "./src/mcp-auto-update.mjs"
|
|
52
53
|
},
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"node": ">=18"
|
|
66
67
|
},
|
|
67
68
|
"scripts": {
|
|
68
|
-
"test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/uninstall.mjs"
|
|
69
|
+
"test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/uninstall.mjs"
|
|
69
70
|
},
|
|
70
71
|
"dependencies": {
|
|
71
72
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -268,6 +268,87 @@ export function upsertSession({
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// ── Cross-PC remote rows (presence-relay.mjs is the only intended caller) ───
|
|
272
|
+
// Writes rows RECEIVED from the cloud presence channel into the same per-brain
|
|
273
|
+
// lane every reader already renders (peer footer, brain_sync peers, overlap
|
|
274
|
+
// conflicts, doctor) — one rendering path, zero reader changes. Precedence is
|
|
275
|
+
// explicit (D3, 2026-08-01): a row claiming THIS machine is refused (own-echo
|
|
276
|
+
// insurance beyond the frame-level drop), and a session id that already exists
|
|
277
|
+
// as a LOCAL row is never overwritten by a cloud frame — a session on this
|
|
278
|
+
// machine renders once, as local. Rows land with via:'cloud' + channelSeen.cloud
|
|
279
|
+
// so existing TTL pruning governs their lifetime (P3) and buildPresenceFrame
|
|
280
|
+
// refuses to re-broadcast them (loop prevention).
|
|
281
|
+
export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID, home, now = Date.now() }) {
|
|
282
|
+
if (!brainPath || !Array.isArray(rows) || !rows.length) return withWriteVerdict([], false, 'nothing-to-write');
|
|
283
|
+
const laneFile = laneFileFor(brainPath, home);
|
|
284
|
+
const lockFile = laneFile + '.lock';
|
|
285
|
+
const gotLock = acquireLock(lockFile);
|
|
286
|
+
if (!gotLock) return withWriteVerdict(listActiveSessions({ brainPath, home, now }), false, 'lane-locked');
|
|
287
|
+
try {
|
|
288
|
+
const data = readLane(laneFile);
|
|
289
|
+
const sessions = pruneSessions(data.sessions, now);
|
|
290
|
+
for (const row of rows) {
|
|
291
|
+
if (!row?.id || !row.machine || row.via !== 'cloud') continue;
|
|
292
|
+
if (String(row.machine) === String(machineId)) continue; // never a remote row for this machine
|
|
293
|
+
const existingIdx = sessions.findIndex((session) => session.id === row.id);
|
|
294
|
+
if (existingIdx >= 0 && sessions[existingIdx].via !== 'cloud') continue; // local row wins
|
|
295
|
+
const previous = existingIdx >= 0 ? sessions[existingIdx] : {};
|
|
296
|
+
const next = {
|
|
297
|
+
...previous,
|
|
298
|
+
id: String(row.id),
|
|
299
|
+
project: path.basename(path.dirname(brainPath)),
|
|
300
|
+
client: String(row.client || previous.client || 'unknown'),
|
|
301
|
+
surface: row.surface ?? previous.surface ?? null,
|
|
302
|
+
branch: row.branch ?? previous.branch ?? null,
|
|
303
|
+
intent: String(row.intent || '').slice(0, 160),
|
|
304
|
+
files: normalizeFiles(row.files),
|
|
305
|
+
machine: String(row.machine),
|
|
306
|
+
host: row.host ?? previous.host ?? null,
|
|
307
|
+
via: 'cloud',
|
|
308
|
+
channels: ['cloud'],
|
|
309
|
+
channelSeen: { cloud: now }, // receiver clock (P4) — frame time never decides freshness
|
|
310
|
+
startedAt: previous.startedAt || now,
|
|
311
|
+
lastSeen: now,
|
|
312
|
+
};
|
|
313
|
+
if (existingIdx >= 0) sessions[existingIdx] = next;
|
|
314
|
+
else sessions.push(next);
|
|
315
|
+
}
|
|
316
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
317
|
+
fs.writeFileSync(laneFile, JSON.stringify({
|
|
318
|
+
...data,
|
|
319
|
+
sessions: sessions.slice(-40),
|
|
320
|
+
messages: capMessages(pruneMessages(data.messages, now), 30),
|
|
321
|
+
}));
|
|
322
|
+
return withWriteVerdict(sessions.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0)), true);
|
|
323
|
+
} finally {
|
|
324
|
+
if (gotLock) releaseLock(lockFile);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Consent revoked, or the channel deliberately left: receive-display stops too
|
|
329
|
+
// (symmetric consent, P9). Removes ONLY cloud-sourced rows — local presence is
|
|
330
|
+
// untouched, so degradation lands exactly on today's local-only behavior.
|
|
331
|
+
export function purgeRemoteSessions({ brainPath, home, now = Date.now() }) {
|
|
332
|
+
if (!brainPath) return [];
|
|
333
|
+
const laneFile = laneFileFor(brainPath, home);
|
|
334
|
+
const lockFile = laneFile + '.lock';
|
|
335
|
+
const gotLock = acquireLock(lockFile);
|
|
336
|
+
if (!gotLock) return listActiveSessions({ brainPath, home, now });
|
|
337
|
+
try {
|
|
338
|
+
const data = readLane(laneFile);
|
|
339
|
+
const sessions = pruneSessions(data.sessions, now).filter((session) => session.via !== 'cloud');
|
|
340
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
341
|
+
fs.writeFileSync(laneFile, JSON.stringify({
|
|
342
|
+
...data,
|
|
343
|
+
sessions,
|
|
344
|
+
messages: capMessages(pruneMessages(data.messages, now), 30),
|
|
345
|
+
}));
|
|
346
|
+
return sessions;
|
|
347
|
+
} finally {
|
|
348
|
+
if (gotLock) releaseLock(lockFile);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
271
352
|
export function removeSession({ brainPath, id, channel = null, home, now = Date.now() }) {
|
|
272
353
|
if (!brainPath || !id) return [];
|
|
273
354
|
const laneFile = laneFileFor(brainPath, home);
|
package/src/klypix-format.mjs
CHANGED
|
@@ -9,8 +9,36 @@
|
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
12
14
|
import { generateKeyBetween } from 'fractional-indexing';
|
|
13
15
|
|
|
16
|
+
// ── Card author identity (team attribution, 2026-08-01) ─────────────────────
|
|
17
|
+
// `createdBy: 'agent'` says WHAT wrote a card; on a team brain the question is
|
|
18
|
+
// WHOSE agent. Identity rides the same source as the dev's commits — git
|
|
19
|
+
// `user.name` in the project — so brain attribution matches git attribution
|
|
20
|
+
// with zero configuration; `KLYPIX_AUTHOR` overrides, OS account name is the
|
|
21
|
+
// fallback, and on total failure the field is simply absent (additive — older
|
|
22
|
+
// readers and older cards are untouched; merge preserves item bytes verbatim,
|
|
23
|
+
// so authorship survives every sync route).
|
|
24
|
+
let cachedAuthor;
|
|
25
|
+
export function resolveAuthor() {
|
|
26
|
+
if (cachedAuthor !== undefined) return cachedAuthor;
|
|
27
|
+
const env = String(process.env.KLYPIX_AUTHOR || '').trim();
|
|
28
|
+
if (env) return (cachedAuthor = env.slice(0, 80));
|
|
29
|
+
try {
|
|
30
|
+
const name = execFileSync('git', ['config', 'user.name'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 })
|
|
31
|
+
.toString().trim();
|
|
32
|
+
if (name) return (cachedAuthor = name.slice(0, 80));
|
|
33
|
+
} catch { /* not a repo / git absent — fall through */ }
|
|
34
|
+
try { cachedAuthor = String(os.userInfo().username || '').slice(0, 80) || null; }
|
|
35
|
+
catch { cachedAuthor = null; }
|
|
36
|
+
return cachedAuthor;
|
|
37
|
+
}
|
|
38
|
+
// Test seam: clears the per-process cache so env overrides can be exercised.
|
|
39
|
+
export function __resetAuthorCache() { cachedAuthor = undefined; }
|
|
40
|
+
const authorField = () => { const a = resolveAuthor(); return a ? { author: a } : {}; };
|
|
41
|
+
|
|
14
42
|
// Valid fractional-indexing z-keys. Hand-rolled keys (e.g. 'a0000' / 'z00013')
|
|
15
43
|
// are REJECTED by the fractional-indexing lib the KLYPIX app uses and crash it
|
|
16
44
|
// the moment you edit such a canvas — so the writer MUST emit lib-valid keys.
|
|
@@ -91,6 +119,20 @@ export async function parseKlypix(buffer) {
|
|
|
91
119
|
if (!canvasRaw) throw new Error('Not a valid .klypix/.any — no canvas.json inside.');
|
|
92
120
|
|
|
93
121
|
const manifest = manifestRaw ? JSON.parse(manifestRaw) : null;
|
|
122
|
+
// Forward-compat guard: this engine reads format v4. A file stamped by a
|
|
123
|
+
// FUTURE format must be refused loudly, never parsed blindly — with brains
|
|
124
|
+
// syncing between machines (git / Brain Sync), mixed versions are a
|
|
125
|
+
// guaranteed state, and a blind parse here feeds every downstream writer
|
|
126
|
+
// (merge, arrange, capture) a structure it does not understand. The merge
|
|
127
|
+
// driver inherits this automatically: the throw exits it non-zero, which
|
|
128
|
+
// degrades to a normal manual git conflict.
|
|
129
|
+
const KLYPIX_FORMAT_CEILING = 4;
|
|
130
|
+
if (manifest && manifest.format === 'klypix' && Number(manifest.version) > KLYPIX_FORMAT_CEILING) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`This .klypix was saved by a newer format (v${manifest.version}); this engine reads up to v${KLYPIX_FORMAT_CEILING}. ` +
|
|
133
|
+
'Update KLYPIX / klypix-mcp instead of parsing it — a blind read could damage it.'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
94
136
|
const canvas = JSON.parse(canvasRaw);
|
|
95
137
|
// v4 manifests are {format:"klypix", version:4}; positions presence is the
|
|
96
138
|
// robust fallback (legacy .any keeps an inline items array, no positions).
|
|
@@ -272,7 +314,7 @@ export async function buildKlypix(spec) {
|
|
|
272
314
|
const itemJson = (card, w) => {
|
|
273
315
|
if (card.type === 'text') {
|
|
274
316
|
return {
|
|
275
|
-
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
317
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
|
|
276
318
|
content: String(card.text ?? ''), fontSize: FONT,
|
|
277
319
|
// PLAIN text (no border) renders at max-content width unless
|
|
278
320
|
// authoredWidth pins the wrap — without it a long single line
|
|
@@ -284,7 +326,7 @@ export async function buildKlypix(spec) {
|
|
|
284
326
|
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
285
327
|
};
|
|
286
328
|
}
|
|
287
|
-
return { type: card.type, locked: false, createdAt: now, createdBy: 'agent', ...(card._raw || {}) };
|
|
329
|
+
return { type: card.type, locked: false, createdAt: now, createdBy: 'agent', ...authorField(), ...(card._raw || {}) };
|
|
288
330
|
};
|
|
289
331
|
|
|
290
332
|
const zip = new JSZip();
|
|
@@ -369,7 +411,7 @@ export async function appendToKlypix(buffer, addition) {
|
|
|
369
411
|
const nextZKey = makeZKeyGen(existingTop);
|
|
370
412
|
for (const a of added) {
|
|
371
413
|
zip.file(`items/${shard(a.id)}/${a.id}.json`, JSON.stringify({
|
|
372
|
-
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
414
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
|
|
373
415
|
...(a.card.createdVia ? { createdVia: String(a.card.createdVia) } : {}),
|
|
374
416
|
content: String(a.card.text), fontSize: FONT,
|
|
375
417
|
color: a.card.color || '#1a1a1f', border: !!a.card.border, borderColor: '#1e1e2e',
|
|
@@ -542,8 +584,10 @@ export async function appendIntoContainers(buffer, addition) {
|
|
|
542
584
|
const id = `txt_${rand()}`;
|
|
543
585
|
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify({
|
|
544
586
|
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
545
|
-
// Provenance:
|
|
546
|
-
|
|
587
|
+
// Provenance: WHOSE agent (git user.name — matches commit identity)…
|
|
588
|
+
...authorField(),
|
|
589
|
+
// …and WHICH agent remembered this (claude-code / cursor /
|
|
590
|
+
// cline / …) — both additive fields, ignored by older readers.
|
|
547
591
|
...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
|
|
548
592
|
// Evidence anchors (file:line / PR#) — additive, ignored by older readers.
|
|
549
593
|
...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
|
|
@@ -3837,7 +3881,7 @@ export async function applyGarden(buffer, { syntheses = [] } = {}) {
|
|
|
3837
3881
|
// `sources` = machine lineage (which originals fed this synthesis, with
|
|
3838
3882
|
// their birth dates) — as_of and future provenance passes read the
|
|
3839
3883
|
// field, never the prose.
|
|
3840
|
-
zip.file(`items/${shard(sid)}/${sid}.json`, JSON.stringify({ type: 'text', locked: false, createdAt: now, createdBy: 'agent', createdVia: 'gardener', content, fontSize: 12, color: '#e8e8ed', border: true, borderColor: 'rgba(59,130,246,0.6)', heading: false, sources: area.candidates.map(c => ({ id: c.id, createdAt: c.createdAt || 0 })) }));
|
|
3884
|
+
zip.file(`items/${shard(sid)}/${sid}.json`, JSON.stringify({ type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(), createdVia: 'gardener', content, fontSize: 12, color: '#e8e8ed', border: true, borderColor: 'rgba(59,130,246,0.6)', heading: false, sources: area.candidates.map(c => ({ id: c.id, createdAt: c.createdAt || 0 })) }));
|
|
3841
3885
|
canvas.positions[sid] = { x: ctnPos.x + 20, y: ctnPos.y + (ctnPos.h || 0) + 10, w: 300, h: measureCardH(content), zKey: nextZKey(), zIndex: canvas.order.length, parentId: area.containerId };
|
|
3842
3886
|
canvas.order.push(sid);
|
|
3843
3887
|
newCards.push(sid);
|
|
@@ -4617,7 +4661,7 @@ export async function buildKlypixMap(spec) {
|
|
|
4617
4661
|
const id = `txt_${rand()}_${ai}_${ci}`;
|
|
4618
4662
|
const h = measured[ci];
|
|
4619
4663
|
items[id] = {
|
|
4620
|
-
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
4664
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent', ...authorField(),
|
|
4621
4665
|
content: String(c.text), fontSize: FONT,
|
|
4622
4666
|
color: c.color || '#e8e8ed', border: true,
|
|
4623
4667
|
borderColor: c.color || 'rgba(16,185,129,0.35)',
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Cross-PC presence relay core — PURE frame logic, no IO of any kind.
|
|
2
|
+
//
|
|
3
|
+
// Extends the machine-local presence lane (agent-presence.mjs) across machines:
|
|
4
|
+
// Dev A's sessions and Dev B's sessions on the same shared brain surface each
|
|
5
|
+
// other's declared intent, expected files, and one-line messages. The transport
|
|
6
|
+
// (one Supabase Realtime channel per shared brain, keyed by the Brain Sync
|
|
7
|
+
// blobId stamped in the brain manifest — brain-sync-core.mjs readCloudLink) is
|
|
8
|
+
// OWNED BY THE DESKTOP APP; this module only builds, validates, and merges the
|
|
9
|
+
// frames that ride it, so every rule here is unit-testable with a mock channel.
|
|
10
|
+
//
|
|
11
|
+
// GOVERNING PROPERTIES (do not weaken):
|
|
12
|
+
// 1. Presence is ADVISORY and EPHEMERAL. It never blocks, never gates a
|
|
13
|
+
// write, and writes NOTHING to any brain file — its worst failure is a
|
|
14
|
+
// missing or stale advisory, never corruption. This module deliberately
|
|
15
|
+
// imports only node:crypto (test/presence-relay.mjs asserts it): no fs,
|
|
16
|
+
// no network, no brain format API can even be reached from here.
|
|
17
|
+
// 2. METADATA ONLY. A frame carries at most: session id, hashed machine id,
|
|
18
|
+
// host label, client, surface, branch, the one-line declared intent,
|
|
19
|
+
// canonical repo-relative expected-file keys, and an informational send
|
|
20
|
+
// time. Never cwd, pid, card text, file contents, diffs, or screen data.
|
|
21
|
+
// buildPresenceFrame constructs by WHITELIST — unknown row fields cannot
|
|
22
|
+
// leak because nothing copies them.
|
|
23
|
+
// 3. DEFAULT OFF, symmetric consent. presenceConsentAllows() gates BOTH
|
|
24
|
+
// directions at this seam (relayOutbound / relayInbound): no consent
|
|
25
|
+
// record ⇒ zero outbound frames AND no receive-display of others.
|
|
26
|
+
// 4. Receiver-clock truth (P4): a peer's freshness is stamped with the
|
|
27
|
+
// RECEIVER's clock at accept time. frame.sentAt is informational only —
|
|
28
|
+
// a machine with a wrong clock can neither appear permanently fresh nor
|
|
29
|
+
// permanently stale.
|
|
30
|
+
// 5. Old clients never crash on new frames (P7): every accept function
|
|
31
|
+
// returns null for an unknown wire version or malformed frame, silently.
|
|
32
|
+
import crypto from 'crypto';
|
|
33
|
+
|
|
34
|
+
export const PRESENCE_WIRE_VERSION = 1;
|
|
35
|
+
export const PRESENCE_HEARTBEAT_MS = 60_000;
|
|
36
|
+
// Consent record contract (mirrors src/services/screenCloudConsent.ts in the
|
|
37
|
+
// desktop app: versioned, revocable, default-off, checked at send time).
|
|
38
|
+
export const PRESENCE_CONSENT_VERSION = 1;
|
|
39
|
+
export const PRESENCE_CONSENT_PURPOSE = 'session-presence';
|
|
40
|
+
export const PRESENCE_CONSENT_SCOPE = 'metadata-only';
|
|
41
|
+
// Cross-PC lane-message dedup prefix: also the marker that stops a received
|
|
42
|
+
// message from being re-broadcast (loop prevention for the message lane).
|
|
43
|
+
export const XPC_DEDUPE_PREFIX = 'xpc:';
|
|
44
|
+
|
|
45
|
+
const sha16 = (value) => crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 16);
|
|
46
|
+
const compact = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
|
47
|
+
const str = (value, max) => compact(value).slice(0, max);
|
|
48
|
+
|
|
49
|
+
// Canonical wire form of an expected-file declaration: repo-relative, lowercase,
|
|
50
|
+
// forward slashes — the SAME fold mcp-presence.mjs normalizeFileKey applies at
|
|
51
|
+
// compare time, applied at SEND time so two machines with different clone roots
|
|
52
|
+
// ("E:/work/repo" vs "/home/dev/repo") produce identical keys. A path that
|
|
53
|
+
// cannot be placed under `root` is DROPPED from the wire (unlike the local
|
|
54
|
+
// comparator, which keeps it): an absolute path outside the repo is local
|
|
55
|
+
// machine structure — usernames, drive layout — and metadata-only (property 2)
|
|
56
|
+
// wins over completeness for anything that leaves the machine.
|
|
57
|
+
export function canonicalWireFiles(files, root) {
|
|
58
|
+
const rootKey = String(root || '')
|
|
59
|
+
.replace(/\\/g, '/')
|
|
60
|
+
.replace(/\/+$/, '')
|
|
61
|
+
.trim()
|
|
62
|
+
.toLowerCase();
|
|
63
|
+
const out = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const file of Array.isArray(files) ? files : []) {
|
|
66
|
+
const raw = String(file || '')
|
|
67
|
+
.replace(/\\/g, '/')
|
|
68
|
+
.replace(/^\.\//, '')
|
|
69
|
+
.trim()
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
if (!raw) continue;
|
|
72
|
+
let key = raw;
|
|
73
|
+
if (/^([a-z]:)?\//.test(raw)) {
|
|
74
|
+
if (!rootKey || !raw.startsWith(`${rootKey}/`)) continue; // outside the repo — never leaves the machine
|
|
75
|
+
key = raw.slice(rootKey.length + 1);
|
|
76
|
+
}
|
|
77
|
+
if (!key || seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
out.push(key);
|
|
80
|
+
}
|
|
81
|
+
return out.slice(0, 20);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One lane row → one wire frame, or null when the row must not be broadcast:
|
|
85
|
+
// a row this relay itself wrote from the channel (via:'cloud') would loop, and
|
|
86
|
+
// a row with no session id is unaddressable. Constructed by whitelist — this
|
|
87
|
+
// is the ONLY place a frame is assembled, so the metadata-only guarantee is
|
|
88
|
+
// structural, not a filter that can miss a field.
|
|
89
|
+
export function buildPresenceFrame(session, { machineId, hostLabel = null, root = null, now = Date.now() } = {}) {
|
|
90
|
+
if (!session || !session.id || !machineId) return null;
|
|
91
|
+
if (session.via === 'cloud') return null; // loop prevention: never re-broadcast a received row
|
|
92
|
+
return {
|
|
93
|
+
v: PRESENCE_WIRE_VERSION,
|
|
94
|
+
kind: 'presence',
|
|
95
|
+
sid: str(session.id, 64),
|
|
96
|
+
machine: str(machineId, 40),
|
|
97
|
+
host: hostLabel ? str(hostLabel, 40) : null,
|
|
98
|
+
client: str(session.client || 'unknown', 40),
|
|
99
|
+
surface: session.surface ? str(session.surface, 40) : null,
|
|
100
|
+
branch: session.branch ? str(session.branch, 80) : null,
|
|
101
|
+
intent: str(session.intent, 160),
|
|
102
|
+
files: canonicalWireFiles(session.files, root),
|
|
103
|
+
sentAt: Number(now) || Date.now(), // informational only — see property 4
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// One received frame → one lane row, or null (silently) when the frame is not
|
|
108
|
+
// ours to render: unknown wire version (P7 — an old client must never crash on
|
|
109
|
+
// a new schema, and vice versa), malformed, or an echo of this machine's own
|
|
110
|
+
// broadcast (loop prevention + D3 precedence: a session on THIS machine renders
|
|
111
|
+
// once, as local). lastSeen/channelSeen are stamped with the RECEIVER's clock.
|
|
112
|
+
export function acceptPresenceFrame(frame, { machineId, now = Date.now() } = {}) {
|
|
113
|
+
if (!frame || typeof frame !== 'object') return null;
|
|
114
|
+
if (frame.v !== PRESENCE_WIRE_VERSION || frame.kind !== 'presence') return null;
|
|
115
|
+
const sid = str(frame.sid, 64);
|
|
116
|
+
const machine = str(frame.machine, 40);
|
|
117
|
+
if (!sid || !machine) return null;
|
|
118
|
+
if (machineId && machine === String(machineId)) return null; // own echo
|
|
119
|
+
return {
|
|
120
|
+
id: sid,
|
|
121
|
+
client: str(frame.client || 'unknown', 40) || 'unknown',
|
|
122
|
+
surface: frame.surface ? str(frame.surface, 40) : null,
|
|
123
|
+
branch: frame.branch ? str(frame.branch, 80) : null,
|
|
124
|
+
intent: str(frame.intent, 160),
|
|
125
|
+
files: canonicalWireFiles(frame.files, null),
|
|
126
|
+
machine,
|
|
127
|
+
host: frame.host ? str(frame.host, 40) : null,
|
|
128
|
+
via: 'cloud',
|
|
129
|
+
lastSeen: now,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// One lane message → one wire frame, or null when it must not cross machines:
|
|
134
|
+
// a message this relay injected FROM the channel (xpc: dedupe key) would loop.
|
|
135
|
+
export function buildMessageFrame(message, { machineId, now = Date.now() } = {}) {
|
|
136
|
+
if (!message || !machineId) return null;
|
|
137
|
+
if (String(message.dedupeKey || '').startsWith(XPC_DEDUPE_PREFIX)) return null;
|
|
138
|
+
const text = str(message.text, 400);
|
|
139
|
+
const from = str(message.from, 64);
|
|
140
|
+
if (!text || !from) return null;
|
|
141
|
+
return {
|
|
142
|
+
v: PRESENCE_WIRE_VERSION,
|
|
143
|
+
kind: 'message',
|
|
144
|
+
machine: str(machineId, 40),
|
|
145
|
+
from,
|
|
146
|
+
to: str(message.to, 160) || 'all',
|
|
147
|
+
text,
|
|
148
|
+
sentAt: Number(message.ts) || Number(now) || Date.now(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// One received message frame → arguments for agent-presence postPresenceMessage,
|
|
153
|
+
// or null. The dedupe key is (origin session id + content hash), NOT a message
|
|
154
|
+
// id: an at-least-once transport may re-deliver the same content under a new
|
|
155
|
+
// envelope, and it must still render exactly once (P5). postPresenceMessage's
|
|
156
|
+
// existing dedupeKey mechanism enforces it under the lane lock.
|
|
157
|
+
export function acceptMessageFrame(frame, { machineId } = {}) {
|
|
158
|
+
if (!frame || typeof frame !== 'object') return null;
|
|
159
|
+
if (frame.v !== PRESENCE_WIRE_VERSION || frame.kind !== 'message') return null;
|
|
160
|
+
const machine = str(frame.machine, 40);
|
|
161
|
+
const from = str(frame.from, 64);
|
|
162
|
+
const text = str(frame.text, 400);
|
|
163
|
+
if (!machine || !from || !text) return null;
|
|
164
|
+
if (machineId && machine === String(machineId)) return null; // own echo
|
|
165
|
+
return {
|
|
166
|
+
from,
|
|
167
|
+
to: str(frame.to, 160) || 'all',
|
|
168
|
+
text,
|
|
169
|
+
dedupeKey: `${XPC_DEDUPE_PREFIX}${sha16(`${machine}|${from}|${text.toLowerCase()}`)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Versioned, revocable, default-OFF consent — the shape the desktop stores per
|
|
174
|
+
// linked brain (klypix:brainPresenceConsent:v1:<blobId>). Anything short of an
|
|
175
|
+
// explicit, current-version grant is a NO: null record (never asked), wrong
|
|
176
|
+
// version (re-consent after a scope change), wrong purpose/scope, revoked.
|
|
177
|
+
export function presenceConsentAllows(record) {
|
|
178
|
+
return Boolean(
|
|
179
|
+
record
|
|
180
|
+
&& typeof record === 'object'
|
|
181
|
+
&& record.version === PRESENCE_CONSENT_VERSION
|
|
182
|
+
&& record.decision === 'granted'
|
|
183
|
+
&& record.purpose === PRESENCE_CONSENT_PURPOSE
|
|
184
|
+
&& record.scope === PRESENCE_CONSENT_SCOPE,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Which lane messages are due for broadcast: authored on this machine (never
|
|
189
|
+
// xpc:-injected), newer than the caller's cursor. The caller advances the
|
|
190
|
+
// cursor to the max ts it sent — re-pumps then skip already-sent content, and
|
|
191
|
+
// even a re-send is harmless (receiver dedup, P5).
|
|
192
|
+
export function selectOutboundMessages(messages, { sinceTs = 0 } = {}) {
|
|
193
|
+
return (Array.isArray(messages) ? messages : [])
|
|
194
|
+
.filter((message) => message
|
|
195
|
+
&& Number(message.ts || 0) > Number(sinceTs || 0)
|
|
196
|
+
&& !String(message.dedupeKey || '').startsWith(XPC_DEDUPE_PREFIX));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── The transport seam ───────────────────────────────────────────────────────
|
|
200
|
+
// The desktop relay calls exactly these two functions around its Realtime
|
|
201
|
+
// channel; the conformance harness calls them around a mock. Consent is
|
|
202
|
+
// checked HERE, per call, in both directions — so "no consent ⇒ zero outbound
|
|
203
|
+
// frames" and "no consent ⇒ no receive-display" are assertable at this seam
|
|
204
|
+
// (acceptance gate 3), and a mid-session revoke takes effect on the next
|
|
205
|
+
// heartbeat/frame with no further wiring (P9).
|
|
206
|
+
|
|
207
|
+
export function relayOutbound({
|
|
208
|
+
sessions = [],
|
|
209
|
+
messages = [],
|
|
210
|
+
consent = null,
|
|
211
|
+
machineId,
|
|
212
|
+
hostLabel = null,
|
|
213
|
+
root = null,
|
|
214
|
+
sinceTs = 0,
|
|
215
|
+
now = Date.now(),
|
|
216
|
+
send,
|
|
217
|
+
} = {}) {
|
|
218
|
+
if (!presenceConsentAllows(consent)) return { sent: 0, reason: 'no-consent', maxMessageTs: sinceTs };
|
|
219
|
+
if (typeof send !== 'function') return { sent: 0, reason: 'no-channel', maxMessageTs: sinceTs };
|
|
220
|
+
let sent = 0;
|
|
221
|
+
let maxMessageTs = Number(sinceTs || 0);
|
|
222
|
+
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
223
|
+
const frame = buildPresenceFrame(session, { machineId, hostLabel, root, now });
|
|
224
|
+
if (!frame) continue;
|
|
225
|
+
send(frame);
|
|
226
|
+
sent++;
|
|
227
|
+
}
|
|
228
|
+
for (const message of selectOutboundMessages(messages, { sinceTs })) {
|
|
229
|
+
const frame = buildMessageFrame(message, { machineId, now });
|
|
230
|
+
if (!frame) continue;
|
|
231
|
+
send(frame);
|
|
232
|
+
sent++;
|
|
233
|
+
maxMessageTs = Math.max(maxMessageTs, Number(message.ts || 0));
|
|
234
|
+
}
|
|
235
|
+
return { sent, reason: null, maxMessageTs };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function relayInbound(frame, { consent = null, machineId, now = Date.now() } = {}) {
|
|
239
|
+
if (!presenceConsentAllows(consent)) return null; // symmetric: no consent ⇒ no receive-display
|
|
240
|
+
if (!frame || typeof frame !== 'object') return null;
|
|
241
|
+
if (frame.kind === 'presence') {
|
|
242
|
+
const row = acceptPresenceFrame(frame, { machineId, now });
|
|
243
|
+
return row ? { type: 'presence', row } : null;
|
|
244
|
+
}
|
|
245
|
+
if (frame.kind === 'message') {
|
|
246
|
+
const message = acceptMessageFrame(frame, { machineId });
|
|
247
|
+
return message ? { type: 'message', message } : null;
|
|
248
|
+
}
|
|
249
|
+
return null; // unknown kind — a future schema this build doesn't know (P7)
|
|
250
|
+
}
|