@wipcomputer/wip-ldm-os 0.4.73-alpha.29 → 0.4.73-alpha.30

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/ldm.js CHANGED
@@ -446,6 +446,98 @@ function syncInboxCheckHook() {
446
446
  return changed;
447
447
  }
448
448
 
449
+ // ── Inbox rewake hook sync ──
450
+ //
451
+ // Deploys src/hooks/inbox-rewake-hook.mjs to ~/.ldm/library/hooks/ and
452
+ // wires it into ~/.claude/settings.json as a Stop hook with
453
+ // `asyncRewake: true`. This is the autonomous push layer that wakes
454
+ // an idle Claude Code session when a bridge message arrives, without
455
+ // the user having to type anything.
456
+ //
457
+ // Mechanics: the Stop hook fires after every CC turn. The rewake hook
458
+ // acquires a per-session lock file (so concurrent Stop-event spawns do
459
+ // not stack), then holds a long-lived fs.watch on ~/.ldm/messages/.
460
+ // When a matching message file arrives, the hook writes the message to
461
+ // stderr and exits with code 2. The CC harness wraps that stderr into
462
+ // a system-reminder task-notification that wakes the idle model or
463
+ // gets injected mid-query if the model is busy. See Claude Code's
464
+ // `src/utils/hooks.ts` asyncRewake path for the exact mechanism.
465
+ //
466
+ // This closes the layer 1 gap from:
467
+ // ai/product/plans-prds/bridge/2026-04-11--cc-mini--autonomous-push-architecture.md
468
+ //
469
+ // Layers 2-4 (UserPromptSubmit inbox-check hook, SessionStart boot
470
+ // hook, manual lesa_check_inbox) remain as independent fallbacks.
471
+ //
472
+ // Idempotent: subsequent installs update the file only if its contents
473
+ // changed, and only add the settings.json entry if it isn't already
474
+ // wired to the exact same command path.
475
+ function syncInboxRewakeHook() {
476
+ const srcHook = join(__dirname, '..', 'src', 'hooks', 'inbox-rewake-hook.mjs');
477
+ const destHook = join(LDM_ROOT, 'library', 'hooks', 'inbox-rewake-hook.mjs');
478
+ let changed = false;
479
+
480
+ if (!existsSync(srcHook)) return false;
481
+
482
+ // 1. File deploy: copy src/hooks/inbox-rewake-hook.mjs to ~/.ldm/library/hooks/
483
+ try {
484
+ const srcContent = readFileSync(srcHook, 'utf8');
485
+ let destContent = '';
486
+ try { destContent = readFileSync(destHook, 'utf8'); } catch {}
487
+
488
+ if (srcContent !== destContent) {
489
+ mkdirSync(dirname(destHook), { recursive: true });
490
+ writeFileSync(destHook, srcContent);
491
+ changed = true;
492
+ }
493
+ } catch {
494
+ return false;
495
+ }
496
+
497
+ // 2. Settings.json patch: wire the hook into hooks.Stop as an
498
+ // asyncRewake background hook if absent.
499
+ const settingsPath = join(HOME, '.claude', 'settings.json');
500
+ if (!existsSync(settingsPath)) return changed;
501
+
502
+ try {
503
+ const raw = readFileSync(settingsPath, 'utf8');
504
+ const settings = JSON.parse(raw);
505
+ if (!settings.hooks) settings.hooks = {};
506
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
507
+
508
+ const hookCommand = `node ${destHook}`;
509
+ const alreadyWired = settings.hooks.Stop.some(group =>
510
+ Array.isArray(group.hooks) &&
511
+ group.hooks.some(h =>
512
+ h.type === 'command' &&
513
+ h.command === hookCommand &&
514
+ h.asyncRewake === true,
515
+ ),
516
+ );
517
+
518
+ if (!alreadyWired) {
519
+ settings.hooks.Stop.push({
520
+ hooks: [{
521
+ type: 'command',
522
+ command: hookCommand,
523
+ async: true,
524
+ asyncRewake: true,
525
+ // 6 hours: matches the rewake hook's internal hard timeout.
526
+ // The hook self-terminates well before this on parent death,
527
+ // hard cancel, or match, so this is just a runaway guard.
528
+ timeout: 21600,
529
+ }],
530
+ });
531
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
532
+ changed = true;
533
+ }
534
+ } catch {
535
+ // Settings file malformed or unreadable. Leave it alone.
536
+ }
537
+
538
+ return changed;
539
+ }
540
+
449
541
  // ── Catalog helpers ──
450
542
 
451
543
  function loadCatalog() {
@@ -2494,6 +2586,14 @@ async function cmdInstallCatalog() {
2494
2586
  console.log(' + Inbox-check hook updated (bridge messages surface automatically)');
2495
2587
  }
2496
2588
 
2589
+ // Sync inbox-rewake hook: Stop hook with asyncRewake that watches
2590
+ // ~/.ldm/messages/ in the background and wakes the model when a new
2591
+ // bridge message arrives, without requiring user interaction. Layer 1
2592
+ // of the April 11 autonomous-push-architecture plan.
2593
+ if (syncInboxRewakeHook()) {
2594
+ console.log(' + Inbox-rewake hook updated (autonomous push: wakes on new bridge message)');
2595
+ }
2596
+
2497
2597
  // Deploy git pre-commit hook on every install (not just init)
2498
2598
  const hooksDir = join(LDM_ROOT, 'hooks');
2499
2599
  const preCommitDest = join(hooksDir, 'pre-commit');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.73-alpha.29",
3
+ "version": "0.4.73-alpha.30",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LDM OS Inbox Rewake Hook
4
+ *
5
+ * asyncRewake background hook for Claude Code. Watches ~/.ldm/messages/
6
+ * with fs.watch and, when a new message addressed to this agent:session
7
+ * arrives, writes the message as a system-reminder to stderr and exits
8
+ * with code 2. Claude Code's harness wraps the stderr in a system-reminder
9
+ * task-notification that wakes the model if idle or gets injected mid-query
10
+ * if the model is busy. See:
11
+ *
12
+ * ai/product/plans-prds/bridge/2026-04-11--cc-mini--autonomous-push-architecture.md
13
+ *
14
+ * Attached as a Stop hook with asyncRewake: true. Fires after every CC
15
+ * turn. A lockfile prevents multiple instances from stacking across many
16
+ * Stop events: only the first instance acquires the lock and watches;
17
+ * subsequent instances see the lock held by a live process and exit 0
18
+ * silently. The lock is released when the watching instance exits
19
+ * (message caught, parent dead, hard timeout, or hard cancel).
20
+ *
21
+ * This hook is the "true push" layer 1 from the plan. Layers 2-4
22
+ * (UserPromptSubmit inbox-check hook, SessionStart boot hook, manual
23
+ * lesa_check_inbox) remain as independent fallbacks.
24
+ *
25
+ * Idempotent with inbox-check-hook.mjs: after firing, this hook marks
26
+ * the message file's `read` field to true so the UserPromptSubmit hook
27
+ * on the next user prompt does not re-surface the same message.
28
+ *
29
+ * Zero external dependencies beyond node:fs, node:path, node:os.
30
+ */
31
+
32
+ import {
33
+ existsSync,
34
+ readFileSync,
35
+ readdirSync,
36
+ writeFileSync,
37
+ unlinkSync,
38
+ watch,
39
+ } from 'node:fs';
40
+ import { join, basename } from 'node:path';
41
+ import { homedir } from 'node:os';
42
+
43
+ const HOME = homedir();
44
+ const MESSAGES_DIR = join(HOME, '.ldm', 'messages');
45
+ const LOCK_PATH = join(MESSAGES_DIR, '.rewake.lock');
46
+ const LDM_CONFIG_PATH = join(HOME, '.ldm', 'config.json');
47
+ const TAG = '[inbox-rewake-hook]';
48
+
49
+ // Hard safety timeout: the watcher exits after 6 hours no matter what,
50
+ // so it cannot leak forever if the parent check or lock cleanup misses.
51
+ const HARD_TIMEOUT_MS = 6 * 60 * 60 * 1000;
52
+
53
+ // Parent CC process liveness check: every minute, verify the parent PID
54
+ // is still alive. If the parent died while this background hook is
55
+ // running, exit cleanly so we do not orphan.
56
+ const PARENT_CHECK_INTERVAL_MS = 60 * 1000;
57
+
58
+ // ── Helpers (mirror inbox-check-hook.mjs) ──
59
+
60
+ function readJSON(path) {
61
+ try {
62
+ return JSON.parse(readFileSync(path, 'utf8'));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function getAgentId() {
69
+ const config = readJSON(LDM_CONFIG_PATH);
70
+ if (config?.agents) {
71
+ for (const [id, agent] of Object.entries(config.agents)) {
72
+ if (agent.harness === 'claude-code') return id;
73
+ }
74
+ }
75
+ return 'cc-mini';
76
+ }
77
+
78
+ function getSessionName(input) {
79
+ // Mirror inbox-check-hook.mjs: CC writes /rename labels to
80
+ // ~/.claude/sessions/<pid>.json, and this hook is spawned fresh by CC
81
+ // so ppid = CC PID. Reading the session file picks up /rename and
82
+ // /resume labels without any env var or restart.
83
+ try {
84
+ const ccSessionPath = join(HOME, '.claude', 'sessions', `${process.ppid}.json`);
85
+ const data = JSON.parse(readFileSync(ccSessionPath, 'utf8'));
86
+ if (data.name && typeof data.name === 'string') {
87
+ return data.name;
88
+ }
89
+ } catch {
90
+ // No session file. Normal for non-CC harnesses.
91
+ }
92
+ return (
93
+ process.env.LDM_SESSION_NAME ||
94
+ process.env.CLAUDE_SESSION_NAME ||
95
+ basename(input?.cwd || process.cwd()) ||
96
+ 'default'
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Check if a message's "to" field matches this agent:session.
102
+ * Same logic as inbox-check-hook.mjs so both hooks agree on routing.
103
+ */
104
+ function messageMatchesAgent(to, agentId, sessionName) {
105
+ if (!to) return false;
106
+ if (to === '*' || to === 'all') return true;
107
+ if (to === agentId) return true;
108
+ if (to === `${agentId}:*`) return true;
109
+ if (to === `${agentId}:${sessionName}`) return true;
110
+ if (to === sessionName) return true;
111
+ return false;
112
+ }
113
+
114
+ // ── Lock management ──
115
+ //
116
+ // A single machine may have seven or more concurrent CC sessions, each
117
+ // spawning its own rewake hook on every Stop event. Without a lock, we
118
+ // accumulate fs.watch handles forever and every new message fires all
119
+ // pending hooks simultaneously.
120
+ //
121
+ // The lock is per-session, keyed by agent:session, so different CC
122
+ // sessions do not block each other. The lock file lives at
123
+ // ~/.ldm/messages/.rewake.<agent>-<session>.lock and contains the PID
124
+ // of the watching process. Subsequent hook spawns in the same session
125
+ // see the lock, verify the PID is alive, and exit silently.
126
+
127
+ function lockPathFor(agentId, sessionName) {
128
+ // Replace any filesystem-unfriendly chars with '-' so session names
129
+ // like "memory:crystal" or "brainstorm (oc)" do not produce invalid
130
+ // filenames. Keeps alphanumerics, dashes, underscores.
131
+ const safe = `${agentId}-${sessionName}`.replace(/[^a-zA-Z0-9._-]/g, '-');
132
+ return join(MESSAGES_DIR, `.rewake.${safe}.lock`);
133
+ }
134
+
135
+ function pidIsAlive(pid) {
136
+ try {
137
+ process.kill(pid, 0); // signal 0 is a liveness probe
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function acquireLock(lockPath) {
145
+ try {
146
+ if (existsSync(lockPath)) {
147
+ const raw = readFileSync(lockPath, 'utf8').trim();
148
+ const existing = parseInt(raw, 10);
149
+ if (existing && existing > 0 && pidIsAlive(existing)) {
150
+ return false;
151
+ }
152
+ // Stale lock: previous holder is dead, take over.
153
+ try { unlinkSync(lockPath); } catch {}
154
+ }
155
+ } catch {}
156
+
157
+ try {
158
+ writeFileSync(lockPath, String(process.pid));
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ function releaseLock(lockPath) {
166
+ try {
167
+ if (!existsSync(lockPath)) return;
168
+ const raw = readFileSync(lockPath, 'utf8').trim();
169
+ const pid = parseInt(raw, 10);
170
+ if (pid === process.pid) {
171
+ unlinkSync(lockPath);
172
+ }
173
+ } catch {}
174
+ }
175
+
176
+ // ── Message delivery ──
177
+ //
178
+ // When a match is found, we:
179
+ // 1. Mark the file's `read` field to true (so inbox-check-hook.mjs on
180
+ // the next UserPromptSubmit does not re-surface it).
181
+ // 2. Write the formatted message body to stderr.
182
+ // 3. Release the lock.
183
+ // 4. process.exit(2) to trigger Claude Code's asyncRewake wake path.
184
+
185
+ function markRead(filePath) {
186
+ try {
187
+ const data = readJSON(filePath);
188
+ if (!data) return;
189
+ data.read = true;
190
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
191
+ } catch {
192
+ // Non-fatal. Worst case: inbox-check-hook surfaces the same message
193
+ // on the next UserPromptSubmit. That is still correct behavior; it
194
+ // just means the same message gets two surfaces.
195
+ }
196
+ }
197
+
198
+ function fireMessage(msg, filePath, lockPath, agentId, sessionName) {
199
+ markRead(filePath);
200
+
201
+ const body =
202
+ `== Bridge Push (autonomous) ==\n` +
203
+ `You have 1 new message delivered by the inbox-rewake hook while you were idle. ` +
204
+ `The message was addressed to ${agentId}:${sessionName} and is now marked read in the inbox.\n\n` +
205
+ `[${msg.type || 'chat'}] from ${msg.from || 'unknown'} (${msg.timestamp || 'no timestamp'}):\n` +
206
+ `${msg.body || '(empty)'}\n\n` +
207
+ `Acknowledge or respond as appropriate. Use lesa_check_inbox or ldm_send_message to continue the thread.`;
208
+
209
+ process.stderr.write(body);
210
+ process.stderr.write(`\n${TAG} fired for ${msg.id || '(no id)'} to ${agentId}:${sessionName}\n`);
211
+
212
+ releaseLock(lockPath);
213
+ process.exit(2);
214
+ }
215
+
216
+ // ── Main ──
217
+
218
+ async function main() {
219
+ // Drain stdin even if we ignore it. Hooks receive JSON per the CC
220
+ // hook protocol; we do not need any of the fields here.
221
+ let raw = '';
222
+ try {
223
+ for await (const chunk of process.stdin) raw += chunk;
224
+ } catch {}
225
+
226
+ let input = {};
227
+ try { input = JSON.parse(raw); } catch {}
228
+
229
+ if (!existsSync(MESSAGES_DIR)) {
230
+ // Nothing to watch. Exit silently. A future install will recreate.
231
+ process.exit(0);
232
+ }
233
+
234
+ const agentId = getAgentId();
235
+ const sessionName = getSessionName(input);
236
+ const lockPath = lockPathFor(agentId, sessionName);
237
+ const parentPid = process.ppid;
238
+
239
+ // Try to take the lock. If another instance of this session's rewake
240
+ // hook is already watching, exit silently and let them handle it.
241
+ if (!acquireLock(lockPath)) {
242
+ process.stderr.write(
243
+ `${TAG} another live instance holds ${basename(lockPath)}, exiting\n`,
244
+ );
245
+ process.exit(0);
246
+ }
247
+
248
+ // Guarantee we release the lock on any exit path.
249
+ process.on('exit', () => releaseLock(lockPath));
250
+ process.on('SIGTERM', () => { releaseLock(lockPath); process.exit(0); });
251
+ process.on('SIGINT', () => { releaseLock(lockPath); process.exit(0); });
252
+ process.on('uncaughtException', (err) => {
253
+ process.stderr.write(`${TAG} uncaughtException: ${err.message}\n`);
254
+ releaseLock(lockPath);
255
+ process.exit(0);
256
+ });
257
+
258
+ // Track which message IDs we have already fired for in this run.
259
+ // fs.watch can fire multiple events for a single file write; the
260
+ // in-memory set prevents duplicate firings in the window between
261
+ // writing `read: true` to disk and the next scan picking it up.
262
+ const seen = new Set();
263
+
264
+ function inspectFile(filePath) {
265
+ const data = readJSON(filePath);
266
+ if (!data) return false;
267
+ if (data.read === true) return false;
268
+ if (data.id && seen.has(data.id)) return false;
269
+ if (!messageMatchesAgent(data.to, agentId, sessionName)) return false;
270
+ if (data.id) seen.add(data.id);
271
+
272
+ fireMessage(data, filePath, lockPath, agentId, sessionName);
273
+ return true; // fireMessage exits, but be explicit.
274
+ }
275
+
276
+ function scanDir() {
277
+ try {
278
+ const files = readdirSync(MESSAGES_DIR).filter((f) => f.endsWith('.json'));
279
+ for (const file of files) {
280
+ if (inspectFile(join(MESSAGES_DIR, file))) return true;
281
+ }
282
+ } catch {}
283
+ return false;
284
+ }
285
+
286
+ // Initial scan: catch any messages that arrived between the previous
287
+ // hook instance exiting and this one starting up.
288
+ if (scanDir()) return;
289
+
290
+ // Set up the fs.watch for new messages.
291
+ let watcher;
292
+ try {
293
+ watcher = watch(MESSAGES_DIR, { persistent: true }, (eventType, filename) => {
294
+ if (!filename || !filename.endsWith('.json')) return;
295
+ // Re-scan on every event. fs.watch can coalesce or miss events
296
+ // under load, so scanning the directory is more reliable than
297
+ // trusting the filename argument alone.
298
+ scanDir();
299
+ });
300
+ } catch (e) {
301
+ process.stderr.write(`${TAG} fs.watch failed: ${e.message}\n`);
302
+ releaseLock(lockPath);
303
+ process.exit(0);
304
+ }
305
+
306
+ // Parent process liveness check. If the CC session that spawned us
307
+ // has exited, stop watching and release the lock.
308
+ const parentCheck = setInterval(() => {
309
+ if (!pidIsAlive(parentPid)) {
310
+ process.stderr.write(`${TAG} parent pid ${parentPid} is dead, exiting\n`);
311
+ clearInterval(parentCheck);
312
+ if (watcher) watcher.close();
313
+ releaseLock(lockPath);
314
+ process.exit(0);
315
+ }
316
+ }, PARENT_CHECK_INTERVAL_MS);
317
+
318
+ // Hard safety timeout.
319
+ const hardTimeout = setTimeout(() => {
320
+ process.stderr.write(`${TAG} hard timeout after ${HARD_TIMEOUT_MS / 1000}s\n`);
321
+ clearInterval(parentCheck);
322
+ if (watcher) watcher.close();
323
+ releaseLock(lockPath);
324
+ process.exit(0);
325
+ }, HARD_TIMEOUT_MS);
326
+
327
+ // Let the timers keep the event loop alive. If either timer fires or
328
+ // the watcher fires a message match, the process exits and releases
329
+ // the lock.
330
+ process.stderr.write(
331
+ `${TAG} watching ${MESSAGES_DIR} for ${agentId}:${sessionName} (parent pid ${parentPid})\n`,
332
+ );
333
+ }
334
+
335
+ main().catch((err) => {
336
+ process.stderr.write(`${TAG} fatal in main: ${err && err.message}\n`);
337
+ process.exit(0);
338
+ });