@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32

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 (67) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +136 -14
  3. package/bin/ldm.js +525 -75
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/lib/registry-migrations.mjs +296 -0
  9. package/package.json +24 -3
  10. package/scripts/test-boot-hook-registration.mjs +136 -0
  11. package/scripts/test-boot-payload-trim.mjs +200 -0
  12. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  13. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  14. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  15. package/scripts/test-crc-pair-login-flow.mjs +115 -0
  16. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  17. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  18. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  19. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  20. package/scripts/test-install-prompt-policy.mjs +84 -0
  21. package/scripts/test-installer-skill-directory.mjs +55 -0
  22. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  23. package/scripts/test-installer-target-self-update.mjs +131 -0
  24. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  25. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  26. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  27. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  28. package/scripts/test-ldm-status-timeout.mjs +96 -0
  29. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  30. package/scripts/test-readme-install-prompt.mjs +66 -0
  31. package/shared/boot/boot-config.json +18 -8
  32. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  33. package/shared/rules/security.md +4 -0
  34. package/shared/templates/install-prompt.md +20 -2
  35. package/src/boot/README.md +24 -1
  36. package/src/boot/boot-config.json +18 -8
  37. package/src/boot/boot-hook.mjs +118 -28
  38. package/src/boot/installer.mjs +33 -10
  39. package/src/hosted-mcp/.env.example +4 -0
  40. package/src/hosted-mcp/README.md +37 -0
  41. package/src/hosted-mcp/app/footer.js +74 -0
  42. package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
  43. package/src/hosted-mcp/app/pair.html +165 -57
  44. package/src/hosted-mcp/app/sprites.png +0 -0
  45. package/src/hosted-mcp/app/wip-logo.png +0 -0
  46. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  47. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  48. package/src/hosted-mcp/demo/footer.js +2 -2
  49. package/src/hosted-mcp/demo/index.html +169 -51
  50. package/src/hosted-mcp/demo/login.html +390 -28
  51. package/src/hosted-mcp/demo/privacy.html +4 -214
  52. package/src/hosted-mcp/demo/tos.html +4 -189
  53. package/src/hosted-mcp/deploy.sh +308 -56
  54. package/src/hosted-mcp/docs/self-host.md +268 -0
  55. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  56. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  57. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  58. package/src/hosted-mcp/legal/legal.css +166 -0
  59. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  60. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  61. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  62. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  63. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  64. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  65. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  66. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  67. package/src/hosted-mcp/server.mjs +1681 -166
@@ -6,7 +6,19 @@ Open your AI and paste this:
6
6
 
7
7
  Read https://wip.computer/install/wip-ldm-os.txt
8
8
 
9
- Check if LDM OS is already installed. If it is, run ldm install --dry-run and show me what I have and what's new.
9
+ Use the install document and live local checks as the source of truth.
10
+ Do not search memory or prior notes for this install. Do not pre-load context from `MEMORY.md`, `crystal_search`, local skill dev guides, or other local memory before fetching the install document.
11
+ Do not run GitHub commands during this install flow. Do not run or request approval for `gh release`, `gh api`, or `gh search`. If release notes are not available from local or npm metadata, say that and do not fetch them from GitHub.
12
+
13
+ Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
14
+
15
+ If installed: run `ldm status`, check available npm tracks from the install document, and show me the version table, what's new for each component, and which stable/current, beta, and alpha versions are available. Then ask:
16
+ - Do you have questions?
17
+ - Want to see a dry run?
18
+
19
+ If yes to dry run, use the selected track's dry-run path from the install document.
20
+
21
+ If I say install, use the selected track's install path from the install document, then run `ldm doctor`.
10
22
 
11
23
  If not, walk me through setup and explain:
12
24
 
@@ -19,7 +31,13 @@ Then ask:
19
31
  - Do you have questions?
20
32
  - Want to see a dry run?
21
33
 
22
- If I say yes: Install the CLI first (npm install -g @wipcomputer/wip-ldm-os) and then run ldm install --dry-run.
34
+ If yes to dry run, install the CLI first using the selected track's bootstrap command from the install document.
35
+
36
+ Then run:
37
+ `ldm init --dry-run`
38
+
39
+ If I say install, run:
40
+ `ldm init`
23
41
 
24
42
  Show me exactly what will change. Don't install anything until I say "install".
25
43
 
@@ -8,7 +8,24 @@ Reads 9 files from the Dream Weaver Boot Sequence (SHARED-CONTEXT.md, SOUL.md, C
8
8
 
9
9
  ## Content Budget
10
10
 
11
- ~700 lines, ~3,500 tokens. Under 2% of the context window. Large files (journals, daily logs) are truncated. Missing files are skipped gracefully.
11
+ Large files are line-capped and stale journals are dropped to path-only lines, so the payload stays small. Missing files are skipped gracefully. The last line of every boot payload is a one-line summary (`== Boot payload: N bytes, M lines, K sections. Capped: ... Stale/path-only: ... ==`) so future slowness triage is trivial.
12
+
13
+ ### Per-step line caps
14
+
15
+ Every step has a line cap. If `boot-config.json` sets `maxLines` for a step, that wins. Otherwise these code defaults apply. They live in `boot-hook.mjs`, not just the config template, because the installer preserves an existing user `boot-config.json` and never overwrites it, so a config-only change would never reach existing installs:
16
+
17
+ - `sharedContext`: 80
18
+ - `soul`: 80
19
+ - `context`: 60
20
+ - `repoLocations`: 80
21
+
22
+ When a file is truncated, the injected content ends with a marker naming the full path so the rest can be read on demand.
23
+
24
+ ### Staleness cutoffs
25
+
26
+ `most-recent` steps (journals) inject the newest dated file only if it is fresh. If the newest file is older than `stalenessDays` (default 14, overridable top-level or per-step), the body is NOT injected: a single line with the file path, date, and age is emitted instead. This stops a four-month-old "most recent" journal from being re-injected every session.
27
+
28
+ `daily-logs` steps have the same guard via `dailyLogStalenessDays` (default 2). Day files are today/yesterday by construction, so this is defensive.
12
29
 
13
30
  ## Deploy
14
31
 
@@ -49,6 +66,12 @@ Should output JSON with `hookSpecificOutput.additionalContext` containing all bo
49
66
 
50
67
  `boot-config.json` defines paths and limits for each boot step. Uses `~` shorthand (resolved at runtime). To support a different agent (cc-air), deploy a different config alongside the same script.
51
68
 
69
+ Optional keys:
70
+
71
+ - Top-level `stalenessDays` (default 14) and `dailyLogStalenessDays` (default 2): staleness cutoffs for `most-recent` and `daily-logs` steps. Per-step keys of the same name override the top-level value.
72
+ - Per-step `maxLines`: line cap for that step. Overrides the code default (see Content Budget).
73
+ - Top-level `maxTotalLines` (default 2000): hard safety cap across all steps.
74
+
52
75
  ## Adding a Boot Step
53
76
 
54
77
  1. Add an entry to `boot-config.json` under `steps`
@@ -2,19 +2,23 @@
2
2
  "agentId": "cc-mini",
3
3
  "timezone": "America/Los_Angeles",
4
4
  "maxTotalLines": 2000,
5
+ "stalenessDays": 14,
6
+ "dailyLogStalenessDays": 2,
5
7
  "steps": {
6
8
  "sharedContext": {
7
9
  "path": "~/.openclaw/workspace/SHARED-CONTEXT.md",
8
10
  "label": "SHARED-CONTEXT.md",
9
11
  "stepNumber": 2,
10
- "critical": true
12
+ "critical": true,
13
+ "maxLines": 80
11
14
  },
12
15
  "journals": {
13
16
  "dir": "~/Documents/wipcomputer--mac-mini-01/staff/Parker/Claude Code - Mini/documents/journals",
14
17
  "label": "Most Recent Journal (Parker)",
15
18
  "stepNumber": 3,
16
19
  "maxLines": 80,
17
- "strategy": "most-recent"
20
+ "strategy": "most-recent",
21
+ "stalenessDays": 14
18
22
  },
19
23
  "workspaceDailyLogs": {
20
24
  "dir": "~/.openclaw/workspace/memory",
@@ -22,7 +26,8 @@
22
26
  "stepNumber": 4,
23
27
  "maxLines": 40,
24
28
  "strategy": "daily-logs",
25
- "days": ["today", "yesterday"]
29
+ "days": ["today", "yesterday"],
30
+ "dailyLogStalenessDays": 2
26
31
  },
27
32
  "fullHistory": {
28
33
  "label": "Full History",
@@ -33,19 +38,22 @@
33
38
  "path": "~/.ldm/agents/cc-mini/CONTEXT.md",
34
39
  "label": "CC CONTEXT.md",
35
40
  "stepNumber": 6,
36
- "critical": true
41
+ "critical": true,
42
+ "maxLines": 60
37
43
  },
38
44
  "soul": {
39
45
  "path": "~/.ldm/agents/cc-mini/SOUL.md",
40
46
  "label": "CC SOUL.md",
41
- "stepNumber": 7
47
+ "stepNumber": 7,
48
+ "maxLines": 80
42
49
  },
43
50
  "ccJournals": {
44
51
  "dir": "~/.ldm/agents/cc-mini/memory/journals",
45
52
  "label": "Most Recent CC Journal",
46
53
  "stepNumber": 8,
47
54
  "maxLines": 80,
48
- "strategy": "most-recent"
55
+ "strategy": "most-recent",
56
+ "stalenessDays": 14
49
57
  },
50
58
  "ccDailyLog": {
51
59
  "dir": "~/.ldm/agents/cc-mini/memory/daily",
@@ -53,13 +61,15 @@
53
61
  "stepNumber": 9,
54
62
  "maxLines": 60,
55
63
  "strategy": "daily-logs",
56
- "days": ["today", "yesterday"]
64
+ "days": ["today", "yesterday"],
65
+ "dailyLogStalenessDays": 2
57
66
  },
58
67
  "repoLocations": {
59
68
  "path": "~/.claude/projects/-Users-lesa--openclaw/memory/repo-locations.md",
60
69
  "label": "repo-locations.md",
61
70
  "stepNumber": 10,
62
- "critical": true
71
+ "critical": true,
72
+ "maxLines": 80
63
73
  }
64
74
  }
65
75
  }
@@ -38,18 +38,63 @@ function listDirSafe(dirPath) {
38
38
  }
39
39
  }
40
40
 
41
- function truncateTop(content, maxLines) {
42
- if (!maxLines || !content) return content;
41
+ // Per-step default line caps applied when boot-config.json does not set
42
+ // maxLines for the step. Keyed by the step key in config.steps. Any of these
43
+ // can be overridden per-step via a maxLines value in boot-config.json.
44
+ // Lives in code (not just the config template) because the installer preserves
45
+ // an existing user boot-config.json, so config-template changes never reach
46
+ // existing installs. Defaults must ride along with the hook.
47
+ const DEFAULT_MAX_LINES = {
48
+ sharedContext: 80,
49
+ soul: 80,
50
+ context: 60,
51
+ repoLocations: 80,
52
+ };
53
+
54
+ // Default staleness cutoffs. Overridable via top-level config keys
55
+ // (stalenessDays, dailyLogStalenessDays) or per-step keys of the same name.
56
+ const DEFAULT_STALENESS_DAYS = 14; // most-recent strategy (journals)
57
+ const DEFAULT_DAILY_LOG_STALENESS_DAYS = 2; // daily-logs strategy guard
58
+
59
+ function resolveMaxLines(key, step) {
60
+ if (step.maxLines != null) return step.maxLines;
61
+ if (DEFAULT_MAX_LINES[key] != null) return DEFAULT_MAX_LINES[key];
62
+ return null;
63
+ }
64
+
65
+ // Returns { text, capped }. When truncation happens, the marker names the
66
+ // full path so a future reader can pull the rest.
67
+ function truncateTop(content, maxLines, fullPath) {
68
+ if (!maxLines || !content) return { text: content, capped: false };
43
69
  const lines = content.split('\n');
44
- if (lines.length <= maxLines) return content;
45
- return lines.slice(0, maxLines).join('\n') + `\n[... truncated at ${maxLines} lines, ${lines.length} total ...]`;
70
+ if (lines.length <= maxLines) return { text: content, capped: false };
71
+ const where = fullPath || '(path unknown)';
72
+ const marker = `\n[... truncated at ${maxLines} of ${lines.length} lines. Read the rest: ${where} ...]`;
73
+ return { text: lines.slice(0, maxLines).join('\n') + marker, capped: true };
46
74
  }
47
75
 
48
- function truncateBottom(content, maxLines) {
49
- if (!maxLines || !content) return content;
76
+ function truncateBottom(content, maxLines, fullPath) {
77
+ if (!maxLines || !content) return { text: content, capped: false };
50
78
  const lines = content.split('\n');
51
- if (lines.length <= maxLines) return content;
52
- return `[... showing last ${maxLines} of ${lines.length} lines ...]\n` + lines.slice(-maxLines).join('\n');
79
+ if (lines.length <= maxLines) return { text: content, capped: false };
80
+ const where = fullPath || '(path unknown)';
81
+ const marker = `[... showing last ${maxLines} of ${lines.length} lines. Read the rest: ${where} ...]\n`;
82
+ return { text: marker + lines.slice(-maxLines).join('\n'), capped: true };
83
+ }
84
+
85
+ // Extract a YYYY-MM-DD prefix from a filename, or null if none.
86
+ function dateStrFromName(fileName) {
87
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(fileName || '');
88
+ return m ? m[1] : null;
89
+ }
90
+
91
+ // Whole-day difference (todayStr - dateStr) in days. Both are YYYY-MM-DD.
92
+ // Positive means dateStr is in the past. null if either is unparseable.
93
+ function ageInDays(dateStr, todayStr) {
94
+ const a = Date.parse(`${dateStr}T00:00:00Z`);
95
+ const b = Date.parse(`${todayStr}T00:00:00Z`);
96
+ if (Number.isNaN(a) || Number.isNaN(b)) return null;
97
+ return Math.round((b - a) / 86400000);
53
98
  }
54
99
 
55
100
  function getTodayAndYesterday(timezone) {
@@ -99,6 +144,8 @@ function getDefaultConfig() {
99
144
  agentId: 'cc-mini',
100
145
  timezone: 'America/Los_Angeles',
101
146
  maxTotalLines: 2000,
147
+ stalenessDays: DEFAULT_STALENESS_DAYS,
148
+ dailyLogStalenessDays: DEFAULT_DAILY_LOG_STALENESS_DAYS,
102
149
  steps: {
103
150
  sharedContext: { path: '~/.openclaw/workspace/SHARED-CONTEXT.md', label: 'SHARED-CONTEXT.md', stepNumber: 2, critical: true },
104
151
  journals: { dir: '~/.ldm/agents/cc-mini/memory/journals', label: 'Most Recent CC Journal (Legacy)', stepNumber: 3, maxLines: 80, strategy: 'most-recent' },
@@ -113,52 +160,79 @@ function getDefaultConfig() {
113
160
  };
114
161
  }
115
162
 
116
- function processStep(key, step, dates) {
163
+ function processStep(key, step, dates, config) {
117
164
  // Reminder-only step (e.g. full history)
118
165
  if (step.reminder) {
119
- return { content: step.reminder, loaded: true, fileName: null };
166
+ return { content: step.reminder, loaded: true, fileName: null, capped: false, stale: false };
120
167
  }
121
168
 
169
+ const maxLines = resolveMaxLines(key, step);
170
+
122
171
  // Single file step
123
172
  if (step.path) {
173
+ const resolved = resolvePath(step.path);
124
174
  const content = readFileSafe(step.path);
125
- if (!content) return { content: null, loaded: false, fileName: resolvePath(step.path) };
126
- const trimmed = step.maxLines ? truncateTop(content, step.maxLines) : content;
127
- return { content: trimmed, loaded: true, fileName: resolvePath(step.path) };
175
+ if (!content) return { content: null, loaded: false, fileName: resolved, capped: false, stale: false };
176
+ const { text, capped } = truncateTop(content, maxLines, resolved);
177
+ return { content: text, loaded: true, fileName: resolved, capped, stale: false };
128
178
  }
129
179
 
130
180
  // Directory-based step
131
181
  if (step.dir) {
132
182
  if (step.strategy === 'most-recent') {
133
183
  const fileName = findMostRecent(step.dir);
134
- if (!fileName) return { content: null, loaded: false, fileName: resolvePath(step.dir) };
184
+ if (!fileName) return { content: null, loaded: false, fileName: resolvePath(step.dir), capped: false, stale: false };
135
185
  const fullPath = join(resolvePath(step.dir), fileName);
136
186
  const content = readFileSafe(fullPath);
137
- if (!content) return { content: null, loaded: false, fileName: fullPath };
138
- const trimmed = step.maxLines ? truncateTop(content, step.maxLines) : content;
139
- return { content: trimmed, loaded: true, fileName: `${fileName}` };
187
+ if (!content) return { content: null, loaded: false, fileName: fullPath, capped: false, stale: false };
188
+
189
+ // Staleness cutoff: if the newest file is older than the cutoff, do not
190
+ // inject its body. Emit a single line with the path and date instead.
191
+ const stalenessDays = step.stalenessDays ?? config.stalenessDays ?? DEFAULT_STALENESS_DAYS;
192
+ const fileDate = dateStrFromName(fileName);
193
+ const age = fileDate ? ageInDays(fileDate, dates.today) : null;
194
+ if (age != null && age > stalenessDays) {
195
+ const line = `[stale: newest file is ${fileDate} (${age}d old, cutoff ${stalenessDays}d). Body not injected. Read if needed: ${fullPath}]`;
196
+ return { content: line, loaded: true, fileName, capped: false, stale: true };
197
+ }
198
+
199
+ const { text, capped } = truncateTop(content, maxLines, fullPath);
200
+ return { content: text, loaded: true, fileName, capped, stale: false };
140
201
  }
141
202
 
142
203
  if (step.strategy === 'daily-logs') {
204
+ const dailyStalenessDays = step.dailyLogStalenessDays ?? config.dailyLogStalenessDays ?? DEFAULT_DAILY_LOG_STALENESS_DAYS;
143
205
  const parts = [];
144
206
  let anyLoaded = false;
207
+ let anyCapped = false;
208
+ let anyStale = false;
145
209
  for (const day of (step.days || ['today'])) {
146
210
  const dateStr = day === 'today' ? dates.today : dates.yesterday;
147
211
  const fileName = `${dateStr}.md`;
148
212
  const fullPath = join(resolvePath(step.dir), fileName);
149
213
  const content = readFileSafe(fullPath);
150
- if (content) {
151
- const trimmed = step.maxLines ? truncateBottom(content, step.maxLines) : content;
152
- parts.push(`--- ${day} (${dateStr}) ---\n${trimmed}`);
153
- anyLoaded = true;
214
+ if (!content) continue;
215
+ anyLoaded = true;
216
+
217
+ // Guard: a day file dated older than the cutoff should not normally
218
+ // happen (days are today/yesterday), but if it does, emit path-only.
219
+ const age = ageInDays(dateStr, dates.today);
220
+ if (age != null && age > dailyStalenessDays) {
221
+ parts.push(`--- ${day} (${dateStr}) ---\n[stale: ${age}d old, cutoff ${dailyStalenessDays}d. Body not injected. Read if needed: ${fullPath}]`);
222
+ anyStale = true;
223
+ continue;
154
224
  }
225
+
226
+ const { text, capped } = truncateBottom(content, maxLines, fullPath);
227
+ if (capped) anyCapped = true;
228
+ parts.push(`--- ${day} (${dateStr}) ---\n${text}`);
155
229
  }
156
- if (!anyLoaded) return { content: null, loaded: false, fileName: resolvePath(step.dir) };
157
- return { content: parts.join('\n\n'), loaded: true, fileName: null };
230
+ if (!anyLoaded) return { content: null, loaded: false, fileName: resolvePath(step.dir), capped: false, stale: false };
231
+ return { content: parts.join('\n\n'), loaded: true, fileName: null, capped: anyCapped, stale: anyStale };
158
232
  }
159
233
  }
160
234
 
161
- return { content: null, loaded: false, fileName: null };
235
+ return { content: null, loaded: false, fileName: null, capped: false, stale: false };
162
236
  }
163
237
 
164
238
  async function main() {
@@ -181,6 +255,8 @@ async function main() {
181
255
  const sections = [];
182
256
  const loaded = [];
183
257
  const skipped = [];
258
+ const cappedSteps = [];
259
+ const staleSteps = [];
184
260
  let totalLines = 0;
185
261
 
186
262
  // Sort steps by stepNumber
@@ -189,7 +265,7 @@ async function main() {
189
265
  );
190
266
 
191
267
  for (const [key, step] of stepEntries) {
192
- const result = processStep(key, step, dates);
268
+ const result = processStep(key, step, dates, config);
193
269
 
194
270
  if (result.loaded && result.content) {
195
271
  const criticalTag = step.critical ? ' (CRITICAL)' : '';
@@ -198,6 +274,8 @@ async function main() {
198
274
  sections.push(`${header}\n${result.content}`);
199
275
  loaded.push(`Step ${step.stepNumber}: ${step.label}`);
200
276
  totalLines += result.content.split('\n').length;
277
+ if (result.capped) cappedSteps.push(`Step ${step.stepNumber}`);
278
+ if (result.stale) staleSteps.push(`Step ${step.stepNumber}`);
201
279
  } else {
202
280
  skipped.push(`Step ${step.stepNumber}: ${step.label}`);
203
281
  if (result.fileName) {
@@ -275,16 +353,28 @@ async function main() {
275
353
  } catch {}
276
354
 
277
355
  const elapsed = Date.now() - startTime;
278
- const footer = `== Boot complete. Loaded ${loaded.length}/9 files in ${elapsed}ms. ==`;
356
+ const footer = `== Boot complete. Loaded ${loaded.length} sections in ${elapsed}ms. ==`;
279
357
  if (skipped.length > 0) {
280
358
  sections.push(`${footer}\nSkipped: ${skipped.join(', ')}`);
281
359
  } else {
282
360
  sections.push(footer);
283
361
  }
284
362
 
285
- const additionalContext =
363
+ // Body first, then a one-line payload summary so future slowness triage is
364
+ // trivial: total bytes/lines injected, which steps were line-capped, and
365
+ // which were skipped as stale (path-only).
366
+ const bodyText =
286
367
  `== LDM OS BOOT SEQUENCE (loaded automatically by SessionStart hook) ==\n\n` +
287
368
  sections.join('\n\n');
369
+ const payloadBytes = Buffer.byteLength(bodyText, 'utf-8');
370
+ const payloadLines = bodyText.split('\n').length;
371
+ const summary =
372
+ `== Boot payload: ${payloadBytes} bytes, ${payloadLines} lines, ${loaded.length} sections. ` +
373
+ `Capped: ${cappedSteps.length ? cappedSteps.join(', ') : 'none'}. ` +
374
+ `Stale/path-only: ${staleSteps.length ? staleSteps.join(', ') : 'none'}. ` +
375
+ `Missing: ${skipped.length ? skipped.length : 'none'}. ==`;
376
+
377
+ const additionalContext = `${bodyText}\n\n${summary}`;
288
378
 
289
379
  const output = {
290
380
  hookSpecificOutput: {
@@ -294,7 +384,7 @@ async function main() {
294
384
  };
295
385
 
296
386
  process.stdout.write(JSON.stringify(output));
297
- process.stderr.write(`${TAG} loaded ${loaded.length}/9 files in ${elapsed}ms\n`);
387
+ process.stderr.write(`${TAG} loaded ${loaded.length} sections, ${payloadBytes} bytes in ${elapsed}ms\n`);
298
388
  process.exit(0);
299
389
  }
300
390
 
@@ -142,13 +142,20 @@ export function configureSessionStartHook() {
142
142
  if (!settings.hooks) settings.hooks = {};
143
143
  if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
144
144
 
145
- // Find existing boot-hook entry
146
- const existingIdx = settings.hooks.SessionStart.findIndex((entry) => {
145
+ const isBootEntry = (entry) => {
147
146
  const hooks = entry?.hooks;
148
147
  if (!Array.isArray(hooks)) return false;
149
148
  return hooks.some((h) =>
150
149
  h?.command?.includes('boot-hook') || h?.command?.includes('shared/boot')
151
150
  );
151
+ };
152
+
153
+ // Find ALL boot-hook entries. Duplicate registrations accumulate across
154
+ // installs (10 copies observed 2026-07-04, each running the boot script
155
+ // once per session), so this owns the whole set, not just the first.
156
+ const ownedIdxs = [];
157
+ settings.hooks.SessionStart.forEach((entry, i) => {
158
+ if (isBootEntry(entry)) ownedIdxs.push(i);
152
159
  });
153
160
 
154
161
  const hookEntry = {
@@ -160,16 +167,32 @@ export function configureSessionStartHook() {
160
167
  }],
161
168
  };
162
169
 
163
- if (existingIdx >= 0) {
164
- // Update in place
165
- settings.hooks.SessionStart[existingIdx] = hookEntry;
166
- return 'SessionStart hook updated in settings.json';
167
- } else {
168
- // Append
169
- settings.hooks.SessionStart.push(hookEntry);
170
+ // Collapse duplicates beyond the first. Walk right-to-left so earlier
171
+ // indices stay valid while splicing.
172
+ let removed = 0;
173
+ for (let j = ownedIdxs.length - 1; j >= 1; j--) {
174
+ settings.hooks.SessionStart.splice(ownedIdxs[j], 1);
175
+ removed++;
176
+ }
177
+
178
+ if (ownedIdxs.length > 0) {
179
+ const idx = ownedIdxs[0];
180
+ const identical =
181
+ JSON.stringify(settings.hooks.SessionStart[idx]) === JSON.stringify(hookEntry);
182
+ if (identical && removed === 0) {
183
+ // Nothing to change; do not touch the file.
184
+ return 'SessionStart hook already configured';
185
+ }
186
+ settings.hooks.SessionStart[idx] = hookEntry;
170
187
  writeJSON(CC_SETTINGS, settings);
171
- return 'SessionStart hook added to settings.json';
188
+ return removed > 0
189
+ ? `SessionStart hook updated in settings.json (removed ${removed} duplicate entr${removed === 1 ? 'y' : 'ies'})`
190
+ : 'SessionStart hook updated in settings.json';
172
191
  }
192
+
193
+ settings.hooks.SessionStart.push(hookEntry);
194
+ writeJSON(CC_SETTINGS, settings);
195
+ return 'SessionStart hook added to settings.json';
173
196
  }
174
197
 
175
198
  // ── Registry ──
@@ -1,3 +1,7 @@
1
1
  # Kaleidoscope server environment
2
2
  # Copy to .env and fill in values. Never commit .env itself.
3
3
  DATABASE_URL=postgresql://kaleidoscope:YOUR_PASSWORD@localhost:5432/kaleidoscope
4
+ KALEIDOSCOPE_LIVE_WALL_FILE=/var/lib/wip/kaleidoscope-live-wall.json
5
+ KALEIDOSCOPE_LIVE_WALL_MEDIA_DIR=/var/www/wip.computer/public_html/media/kaleidoscope/generated
6
+ KALEIDOSCOPE_LIVE_WALL_MAX_IMAGE_BYTES=10485760
7
+ KALEIDOSCOPE_LIVE_WALL_FETCH_TIMEOUT_MS=15000
@@ -0,0 +1,37 @@
1
+ # Hosted MCP And Relay
2
+
3
+ This directory contains the public source for the hosted WIP relay that serves `wip.computer`.
4
+
5
+ It includes:
6
+
7
+ - OAuth, passkey, and hosted MCP routes in `server.mjs`;
8
+ - Codex Remote Control relay routes under `/api/codex-relay/*`;
9
+ - nginx snippets for the relay, MCP, and site proxy;
10
+ - Prisma schema and migrations for Postgres-backed account, API key, passkey, device, and wallet state;
11
+ - PM2 and deploy helpers for the WIP-operated VPS.
12
+
13
+ WIP runs the production hosted relay so user setup is easy and works across networks. The source is public so users can inspect the relay path and build their own infrastructure.
14
+
15
+ ## Codex Remote Control WebSocket Abuse Limits
16
+
17
+ The browser relay path enforces app-layer limits after a Remote Control ticket attaches:
18
+
19
+ - max browser frame bytes;
20
+ - max messages per rate window;
21
+ - max browser bytes per rate window;
22
+ - max malformed browser frames;
23
+ - max pending bytes on the daemon socket before forwarding;
24
+ - max browser sockets per `(tenant id, thread id)`;
25
+ - idle connection TTL;
26
+ - env-driven operator kill switch for all tenants or selected tenant ids.
27
+
28
+ Violation logs are metadata-only: reason, tenant id, thread id, and a generated connection id. The relay does not inspect decrypted Remote Control payloads.
29
+
30
+ Operational notes:
31
+
32
+ - rate windows are tumbling windows, not sliding windows, so short bursts can straddle a window boundary;
33
+ - kill switch environment changes take effect after the hosted relay process reloads;
34
+ - idle close runs on a timer, so the close can happen up to one polling interval after the configured TTL;
35
+ - daemon-to-browser Codex output is intentionally not throughput-limited in this browser-abuse slice.
36
+
37
+ For the self-hosting shape, read [docs/self-host.md](docs/self-host.md).
@@ -0,0 +1,74 @@
1
+ // Shared footer for all Kaleidoscope pages (production-owned).
2
+ // Include with: <div id="kscope-footer"></div><script src="/app/footer.js"></script>
3
+ (function() {
4
+ var container = document.getElementById('kscope-footer');
5
+ if (!container) return;
6
+
7
+ var mobile = navigator.maxTouchPoints > 0 && window.innerWidth < 768;
8
+
9
+ // Desktop: fixed at bottom. Mobile: in page flow (below fold).
10
+ if (mobile) {
11
+ container.style.cssText = 'background:#FFFDF5;padding:16px 0;';
12
+ } else {
13
+ container.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#FFFDF5;padding:16px 0;';
14
+ }
15
+
16
+ var inner = document.createElement('div');
17
+ inner.style.cssText = 'max-width:980px;margin:0 auto;padding:0 24px;border-top:1px solid rgba(0,0,0,0.06);padding-top:16px;text-align:left;font-size:13px;color:#a8a4a0;line-height:1.6;';
18
+
19
+ // On mobile, copyright and links on separate lines (like Apple)
20
+ if (mobile) {
21
+ inner.innerHTML = '<p style="margin:0;">WIP Computer, Inc.</p>'
22
+ + '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
23
+ + '<p style="margin:8px 0 0;">Copyright &copy; 2026 WIP Computer, Inc. All rights reserved.</p>'
24
+ + '<p style="margin:4px 0 0;">'
25
+ + '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
26
+ + '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
27
+ + '<p style="margin:4px 0 0;">'
28
+ + '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a></p>'
29
+ + '<p style="margin:4px 0 0;">Made in California.</p>';
30
+ } else {
31
+ inner.innerHTML = '<p style="margin:0;">WIP Computer, Inc.</p>'
32
+ + '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
33
+ + '<p style="margin:8px 0 0;">Copyright &copy; 2026 WIP Computer, Inc. All rights reserved. &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
34
+ + '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
35
+ + '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
36
+ + '<p style="margin:4px 0 0;">'
37
+ + '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a> &nbsp;|&nbsp; '
38
+ + '<a id="localPasskeysToggle" onclick="toggleLocalPasskeys()" style="color:#a8a4a0;text-decoration:none;cursor:pointer;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
39
+ + '<span id="passkeys-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;"></span> '
40
+ + '<span id="passkeys-label">Local passkeys off</span></a></p>'
41
+ + '<p style="margin:4px 0 0;">Made in California.</p>';
42
+ }
43
+
44
+ container.appendChild(inner);
45
+
46
+ // Local passkeys toggle
47
+ if (!window.isLocalPasskeysOn) {
48
+ window.isLocalPasskeysOn = function() { return localStorage.getItem('localPasskeys') === 'on'; };
49
+ }
50
+ if (!window.toggleLocalPasskeys) {
51
+ window.toggleLocalPasskeys = function() {
52
+ var on = isLocalPasskeysOn();
53
+ localStorage.setItem('localPasskeys', on ? 'off' : 'on');
54
+ updatePasskeysDot();
55
+ };
56
+ }
57
+ if (!window.updatePasskeysDot) {
58
+ window.updatePasskeysDot = function() {
59
+ var dot = document.getElementById('passkeys-dot');
60
+ var label = document.getElementById('passkeys-label');
61
+ if (!dot) return;
62
+ if (isLocalPasskeysOn()) {
63
+ dot.style.background = '#2E7D32';
64
+ dot.style.opacity = '1';
65
+ if (label) label.textContent = 'Local passkeys on';
66
+ } else {
67
+ dot.style.background = '#D32F2F';
68
+ dot.style.opacity = '0.4';
69
+ if (label) label.textContent = 'Local passkeys off';
70
+ }
71
+ };
72
+ }
73
+ updatePasskeysDot();
74
+ })();