thevoidforge 21.0.11 → 21.0.13
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/dist/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/wizard/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: React Component with All States
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Every component handles: loading, empty, error, and success states
|
|
6
|
+
* - Keyboard accessible — interactive elements focusable, no mouse-only actions
|
|
7
|
+
* - Semantic HTML — use the right element, not div-with-onClick
|
|
8
|
+
* - Loading states prevent layout shift (skeleton matches content shape)
|
|
9
|
+
* - Error states are actionable — tell the user what to do
|
|
10
|
+
* - Empty states guide — never just show blank space
|
|
11
|
+
*
|
|
12
|
+
* Agents: Legolas (code), Samwise (a11y), Bilbo (copy), Gimli (performance)
|
|
13
|
+
*
|
|
14
|
+
* Framework adaptations:
|
|
15
|
+
* Next.js/React: This file (JSX, hooks, 'use client')
|
|
16
|
+
* Vue: Same 4-state pattern with v-if/v-else, <template> blocks
|
|
17
|
+
* Svelte: Same pattern with {#if}/{:else} blocks
|
|
18
|
+
* Django templates: Conditional blocks for each state, HTMX for loading
|
|
19
|
+
* Rails: Turbo Frames for loading states, partials for each state
|
|
20
|
+
*
|
|
21
|
+
* === Django Templates + HTMX Deep Dive ===
|
|
22
|
+
*
|
|
23
|
+
* {# templates/projects/list.html — same 4-state pattern, no JS framework #}
|
|
24
|
+
* {% if loading %}
|
|
25
|
+
* <div role="status" aria-label="Loading projects">
|
|
26
|
+
* {% for _ in "123" %}<div class="skeleton h-16 rounded-lg bg-gray-100 animate-pulse"></div>{% endfor %}
|
|
27
|
+
* </div>
|
|
28
|
+
* {% elif error %}
|
|
29
|
+
* <div role="alert" class="border border-red-200 bg-red-50 p-4">
|
|
30
|
+
* <p>Couldn't load projects: {{ error }}</p>
|
|
31
|
+
* <button hx-get="{% url 'project-list' %}" hx-target="#project-list">Try again</button>
|
|
32
|
+
* </div>
|
|
33
|
+
* {% elif not projects %}
|
|
34
|
+
* <div class="border-dashed p-8 text-center">No projects yet.</div>
|
|
35
|
+
* {% else %}
|
|
36
|
+
* {% for project in projects %}{% include "projects/_card.html" %}{% endfor %}
|
|
37
|
+
* {% endif %}
|
|
38
|
+
*
|
|
39
|
+
* {# HTMX: loading states via hx-indicator, partial swaps via hx-target #}
|
|
40
|
+
* {# Keyboard: ensure HTMX-swapped content gets focus management via hx-on::after-swap #}
|
|
41
|
+
*
|
|
42
|
+
* === FastAPI + Jinja2 + HTMX Deep Dive ===
|
|
43
|
+
*
|
|
44
|
+
* Same template pattern as Django. FastAPI serves templates via:
|
|
45
|
+
* from fastapi.templating import Jinja2Templates
|
|
46
|
+
* templates = Jinja2Templates(directory="templates")
|
|
47
|
+
*
|
|
48
|
+
* For SPA frontends: FastAPI serves JSON API, React/Vue/Svelte handles the 4 states.
|
|
49
|
+
* For HTMX: same pattern as Django templates above, endpoint returns HTML fragments.
|
|
50
|
+
*
|
|
51
|
+
* The framework changes, the principle doesn't:
|
|
52
|
+
* EVERY data-driven component handles loading, empty, error, and success.
|
|
53
|
+
*
|
|
54
|
+
* === useEffect + Store Interaction Rules ===
|
|
55
|
+
*
|
|
56
|
+
* ANTI-PATTERN: useEffect that depends on a store value it indirectly modifies
|
|
57
|
+
* useEffect(() => {
|
|
58
|
+
* loadData(id); // calls store.set() which changes currentData
|
|
59
|
+
* }, [currentData]); // currentData changes → effect fires → infinite loop
|
|
60
|
+
*
|
|
61
|
+
* CORRECT: depend on the primitive trigger, not the store result
|
|
62
|
+
* useEffect(() => {
|
|
63
|
+
* if (id) loadData(id);
|
|
64
|
+
* }, [id]); // URL param triggers load, not the store value
|
|
65
|
+
*
|
|
66
|
+
* CORRECT: ref guard for load-once effects
|
|
67
|
+
* const loaded = useRef(false);
|
|
68
|
+
* useEffect(() => {
|
|
69
|
+
* if (loaded.current) return;
|
|
70
|
+
* loaded.current = true;
|
|
71
|
+
* loadData(id);
|
|
72
|
+
* }, []);
|
|
73
|
+
*
|
|
74
|
+
* RULE: For every useEffect with store values in its dependency array,
|
|
75
|
+
* verify the effect body does NOT trigger a store update that changes
|
|
76
|
+
* those same dependencies. If it does → infinite render loop.
|
|
77
|
+
*
|
|
78
|
+
* RULE: Never call panelRef.current?.focus() in a useEffect without a
|
|
79
|
+
* ref guard. Arrow function callbacks in parent components change identity
|
|
80
|
+
* on every render, causing the effect to re-run and steal focus.
|
|
81
|
+
*
|
|
82
|
+
* === Collapsible / Accordion Pattern ===
|
|
83
|
+
*
|
|
84
|
+
* When building any expand/collapse UI (accordions, dropdowns, FAQ sections,
|
|
85
|
+
* expandable cards), this ARIA checklist is required:
|
|
86
|
+
*
|
|
87
|
+
* <button
|
|
88
|
+
* type="button" // Not "submit" — prevents form submission
|
|
89
|
+
* aria-expanded={isOpen} // Announces open/closed state
|
|
90
|
+
* aria-controls={contentId} // Links trigger to content panel
|
|
91
|
+
* className="focus-visible:ring-2" // Keyboard focus indicator
|
|
92
|
+
* onClick={() => setIsOpen(!isOpen)}
|
|
93
|
+
* >
|
|
94
|
+
* Section Title
|
|
95
|
+
* </button>
|
|
96
|
+
* {isOpen && (
|
|
97
|
+
* <div id={contentId}> // Must match aria-controls value
|
|
98
|
+
* Panel content
|
|
99
|
+
* </div>
|
|
100
|
+
* )}
|
|
101
|
+
*
|
|
102
|
+
* Use useId() (React 18+) or a stable ID generator for contentId.
|
|
103
|
+
* Keyboard: Enter/Space toggles. This is automatic with <button>.
|
|
104
|
+
*
|
|
105
|
+
* Common mistake: adding aria-expanded but forgetting aria-controls + id.
|
|
106
|
+
* The three are a unit — aria-expanded alone tells screen readers the
|
|
107
|
+
* state but not WHAT content it controls.
|
|
108
|
+
* (Field report #43: custom accordions had aria-expanded but no
|
|
109
|
+
* aria-controls or content id — a11y violation caught in review.)
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
// ── Portal Pattern (iframe-safe overlays) ────────────
|
|
113
|
+
// Any dropdown, popover, modal, or tooltip that must render ABOVE an iframe
|
|
114
|
+
// MUST use createPortal to document.body.
|
|
115
|
+
//
|
|
116
|
+
// Why: Iframes with allow-same-origin create impenetrable stacking contexts.
|
|
117
|
+
// z-index alone will NOT work — the iframe's stacking context always wins.
|
|
118
|
+
//
|
|
119
|
+
// import { createPortal } from 'react-dom';
|
|
120
|
+
//
|
|
121
|
+
// function Dropdown({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) {
|
|
122
|
+
// if (!isOpen) return null;
|
|
123
|
+
// return createPortal(
|
|
124
|
+
// <div style={{ position: 'fixed', zIndex: 9999 }}>{children}</div>,
|
|
125
|
+
// document.body
|
|
126
|
+
// );
|
|
127
|
+
// }
|
|
128
|
+
//
|
|
129
|
+
// Django/HTMX equivalent: render the overlay in a top-level <div> outside the iframe container.
|
|
130
|
+
// (Field report #79: iframe stacking context defeated z-index on map overlay)
|
|
131
|
+
|
|
132
|
+
'use client'
|
|
133
|
+
|
|
134
|
+
import { useState } from 'react'
|
|
135
|
+
|
|
136
|
+
// --- Types co-located with component ---
|
|
137
|
+
interface Project {
|
|
138
|
+
id: string
|
|
139
|
+
name: string
|
|
140
|
+
description: string | null
|
|
141
|
+
createdAt: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface ProjectListProps {
|
|
145
|
+
projects: Project[]
|
|
146
|
+
isLoading: boolean
|
|
147
|
+
error: string | null
|
|
148
|
+
onDelete: (id: string) => void
|
|
149
|
+
onRetry: () => void
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Main component ---
|
|
153
|
+
export function ProjectList({
|
|
154
|
+
projects,
|
|
155
|
+
isLoading,
|
|
156
|
+
error,
|
|
157
|
+
onDelete,
|
|
158
|
+
onRetry,
|
|
159
|
+
}: ProjectListProps) {
|
|
160
|
+
// Loading state — skeleton matches content shape (Gimli — no layout shift)
|
|
161
|
+
if (isLoading) {
|
|
162
|
+
return (
|
|
163
|
+
<div role="status" aria-label="Loading projects">
|
|
164
|
+
<ul className="space-y-3">
|
|
165
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
166
|
+
<li key={i} className="h-16 rounded-lg bg-gray-100 animate-pulse" />
|
|
167
|
+
))}
|
|
168
|
+
</ul>
|
|
169
|
+
<span className="sr-only">Loading projects...</span>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Error state — actionable, not just "something went wrong" (Bilbo — clear copy)
|
|
175
|
+
if (error) {
|
|
176
|
+
return (
|
|
177
|
+
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-4">
|
|
178
|
+
<p className="font-medium text-red-800">Couldn't load your projects</p>
|
|
179
|
+
<p className="mt-1 text-sm text-red-600">{error}</p>
|
|
180
|
+
<button
|
|
181
|
+
onClick={onRetry}
|
|
182
|
+
className="mt-3 text-sm font-medium text-red-700 underline hover:text-red-800"
|
|
183
|
+
>
|
|
184
|
+
Try again
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Empty state — guide the user, don't show blank (Bilbo — helpful copy)
|
|
191
|
+
if (projects.length === 0) {
|
|
192
|
+
return (
|
|
193
|
+
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center">
|
|
194
|
+
<p className="font-medium text-gray-900">No projects yet</p>
|
|
195
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
196
|
+
Create your first project to get started.
|
|
197
|
+
</p>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Success state
|
|
203
|
+
return (
|
|
204
|
+
<ul className="space-y-3" role="list">
|
|
205
|
+
{projects.map((project) => (
|
|
206
|
+
<ProjectCard key={project.id} project={project} onDelete={onDelete} />
|
|
207
|
+
))}
|
|
208
|
+
</ul>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Sub-component ---
|
|
213
|
+
function ProjectCard({
|
|
214
|
+
project,
|
|
215
|
+
onDelete,
|
|
216
|
+
}: {
|
|
217
|
+
project: Project
|
|
218
|
+
onDelete: (id: string) => void
|
|
219
|
+
}) {
|
|
220
|
+
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<li className="flex items-center justify-between rounded-lg border p-4">
|
|
224
|
+
<div>
|
|
225
|
+
<h3 className="font-medium text-gray-900">{project.name}</h3>
|
|
226
|
+
{project.description && (
|
|
227
|
+
<p className="mt-0.5 text-sm text-gray-500">{project.description}</p>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Dangerous action requires confirmation (Radagast — safety) */}
|
|
232
|
+
{confirmDelete ? (
|
|
233
|
+
<div className="flex gap-2" role="group" aria-label="Confirm deletion">
|
|
234
|
+
<button
|
|
235
|
+
onClick={() => {
|
|
236
|
+
onDelete(project.id)
|
|
237
|
+
setConfirmDelete(false)
|
|
238
|
+
}}
|
|
239
|
+
className="rounded bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
|
|
240
|
+
aria-label={`Confirm delete ${project.name}`}
|
|
241
|
+
>
|
|
242
|
+
Delete
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setConfirmDelete(false)}
|
|
246
|
+
className="rounded border px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
247
|
+
>
|
|
248
|
+
Cancel
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
) : (
|
|
252
|
+
<button
|
|
253
|
+
onClick={() => setConfirmDelete(true)}
|
|
254
|
+
className="rounded border px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
255
|
+
aria-label={`Delete ${project.name}`}
|
|
256
|
+
>
|
|
257
|
+
Delete
|
|
258
|
+
</button>
|
|
259
|
+
)}
|
|
260
|
+
</li>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Daemon Process (Heartbeat)
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - PID file with flock to prevent concurrent instances
|
|
6
|
+
* - Unix domain socket for IPC (ADR-1: single writer)
|
|
7
|
+
* - Session token auth (rotated every 24 hours)
|
|
8
|
+
* - Graceful shutdown on SIGTERM (10s deadline)
|
|
9
|
+
* - Sleep/wake recovery with tiered catch-up
|
|
10
|
+
* - Core dumps disabled for processes holding vault keys
|
|
11
|
+
* - Log rotation: daily or at 10MB, retain 7 days
|
|
12
|
+
* - macOS: F_FULLFSYNC for financial files
|
|
13
|
+
*
|
|
14
|
+
* Agents: Dockson (treasury), Heartbeat daemon
|
|
15
|
+
*
|
|
16
|
+
* PRD Reference: §9.7, §9.18, §9.19.2, §9.20.11
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createServer } from 'node:net';
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { writeFile, readFile, unlink, mkdir, open, rename } from 'node:fs/promises';
|
|
22
|
+
import { existsSync, createWriteStream } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { homedir, platform } from 'node:os';
|
|
25
|
+
|
|
26
|
+
const VOIDFORGE_DIR = join(homedir(), '.voidforge');
|
|
27
|
+
const RUN_DIR = join(VOIDFORGE_DIR, 'run');
|
|
28
|
+
const PID_FILE = join(RUN_DIR, 'heartbeat.pid');
|
|
29
|
+
const SOCKET_PATH = join(RUN_DIR, 'heartbeat.sock');
|
|
30
|
+
const TOKEN_FILE = join(VOIDFORGE_DIR, 'heartbeat.token');
|
|
31
|
+
const STATE_FILE = join(VOIDFORGE_DIR, 'heartbeat.json');
|
|
32
|
+
const LOG_FILE = join(VOIDFORGE_DIR, 'heartbeat.log');
|
|
33
|
+
|
|
34
|
+
type DaemonState = 'starting' | 'healthy' | 'degraded' | 'recovering' | 'recovery_failed' | 'shutting_down' | 'stopped';
|
|
35
|
+
|
|
36
|
+
interface HeartbeatState {
|
|
37
|
+
pid: number;
|
|
38
|
+
state: DaemonState;
|
|
39
|
+
startedAt: string;
|
|
40
|
+
lastHeartbeat: string;
|
|
41
|
+
lastEventId: number;
|
|
42
|
+
cultivationState: string;
|
|
43
|
+
activePlatforms: string[];
|
|
44
|
+
activeCampaigns: number;
|
|
45
|
+
todaySpend: number; // Cents
|
|
46
|
+
dailyBudget: number; // Cents
|
|
47
|
+
alerts: string[];
|
|
48
|
+
tokenHealth: Record<string, { status: string; expiresAt: string }>;
|
|
49
|
+
lastAgentMessage?: { agent: string; text: string; timestamp: string };
|
|
50
|
+
// Treasury state (v19.0 — present when stablecoin funding is configured)
|
|
51
|
+
stablecoinBalanceCents?: number;
|
|
52
|
+
bankBalanceCents?: number;
|
|
53
|
+
runwayDays?: number;
|
|
54
|
+
fundingFrozen?: boolean;
|
|
55
|
+
pendingTransferCount?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── PID Management ────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async function writePidFile(): Promise<void> {
|
|
61
|
+
await mkdir(RUN_DIR, { recursive: true, mode: 0o700 });
|
|
62
|
+
const fh = await open(PID_FILE, 'w', 0o600);
|
|
63
|
+
try {
|
|
64
|
+
await fh.writeFile(String(process.pid));
|
|
65
|
+
await fh.sync();
|
|
66
|
+
} finally {
|
|
67
|
+
await fh.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function checkStalePid(): Promise<boolean> {
|
|
72
|
+
if (!existsSync(PID_FILE)) return false;
|
|
73
|
+
try {
|
|
74
|
+
const pid = parseInt(await readFile(PID_FILE, 'utf-8'));
|
|
75
|
+
process.kill(pid, 0); // Check if process exists (throws if not)
|
|
76
|
+
return true; // Another daemon is alive
|
|
77
|
+
} catch {
|
|
78
|
+
await unlink(PID_FILE).catch(() => {});
|
|
79
|
+
return false; // Stale PID — cleaned up
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function removePidFile(): Promise<void> {
|
|
84
|
+
await unlink(PID_FILE).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Session Token ─────────────────────────────────────
|
|
88
|
+
// Rotated every 24 hours (§9.19.15)
|
|
89
|
+
|
|
90
|
+
async function generateSessionToken(): Promise<string> {
|
|
91
|
+
const token = randomBytes(32).toString('hex');
|
|
92
|
+
await mkdir(RUN_DIR, { recursive: true, mode: 0o700 });
|
|
93
|
+
const tmpPath = TOKEN_FILE + '.tmp.' + process.pid;
|
|
94
|
+
const fh = await open(tmpPath, 'w', 0o600);
|
|
95
|
+
try {
|
|
96
|
+
await fh.writeFile(token);
|
|
97
|
+
await fh.sync();
|
|
98
|
+
} finally {
|
|
99
|
+
await fh.close();
|
|
100
|
+
}
|
|
101
|
+
await rename(tmpPath, TOKEN_FILE);
|
|
102
|
+
return token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateToken(provided: string, expected: string): boolean {
|
|
106
|
+
if (provided.length !== expected.length) return false;
|
|
107
|
+
const a = Buffer.from(provided);
|
|
108
|
+
const b = Buffer.from(expected);
|
|
109
|
+
const { timingSafeEqual } = require('node:crypto');
|
|
110
|
+
return timingSafeEqual(a, b);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Socket Server ─────────────────────────────────────
|
|
114
|
+
// JSON-over-HTTP on Unix domain socket (§9.20.11)
|
|
115
|
+
|
|
116
|
+
function createSocketServer(
|
|
117
|
+
sessionToken: string,
|
|
118
|
+
handleRequest: (method: string, path: string, body: unknown, auth: { hasToken: boolean; vaultPassword: string; totpCode: string }) => Promise<{ status: number; body: unknown }>
|
|
119
|
+
): ReturnType<typeof createServer> {
|
|
120
|
+
const MAX_REQUEST_SIZE = 1024 * 1024; // 1MB — R2-NIGHTWING-003: enforce during streaming
|
|
121
|
+
|
|
122
|
+
const server = createServer(async (conn) => {
|
|
123
|
+
let data = '';
|
|
124
|
+
let rejected = false;
|
|
125
|
+
conn.on('data', (chunk) => {
|
|
126
|
+
data += chunk.toString();
|
|
127
|
+
if (data.length > MAX_REQUEST_SIZE && !rejected) {
|
|
128
|
+
rejected = true;
|
|
129
|
+
conn.write('HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\n\r\n{"ok":false,"error":"Request too large"}');
|
|
130
|
+
conn.end();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
conn.on('end', async () => {
|
|
134
|
+
try {
|
|
135
|
+
// Parse HTTP-like request from the socket
|
|
136
|
+
const lines = data.split('\r\n');
|
|
137
|
+
const [method, path] = (lines[0] || 'GET /').split(' ');
|
|
138
|
+
|
|
139
|
+
// Extract auth headers — pass VALUES, not just presence (SEC-001 fix)
|
|
140
|
+
const authHeader = lines.find(l => l.toLowerCase().startsWith('authorization:'));
|
|
141
|
+
const token = authHeader ? authHeader.split(' ').pop() || '' : '';
|
|
142
|
+
const hasToken = validateToken(token, sessionToken);
|
|
143
|
+
|
|
144
|
+
const vaultHeader = lines.find(l => l.toLowerCase().startsWith('x-vault-password:'));
|
|
145
|
+
const vaultPassword = vaultHeader ? vaultHeader.substring(vaultHeader.indexOf(':') + 1).trim() : '';
|
|
146
|
+
|
|
147
|
+
const totpHeader = lines.find(l => l.toLowerCase().startsWith('x-totp-code:'));
|
|
148
|
+
const totpCode = totpHeader ? totpHeader.substring(totpHeader.indexOf(':') + 1).trim() : '';
|
|
149
|
+
|
|
150
|
+
// Parse body (after blank line)
|
|
151
|
+
const bodyStart = data.indexOf('\r\n\r\n');
|
|
152
|
+
|
|
153
|
+
if (rejected) return; // Already rejected during streaming
|
|
154
|
+
|
|
155
|
+
const body = bodyStart >= 0 ? JSON.parse(data.substring(bodyStart + 4) || '{}') : {};
|
|
156
|
+
|
|
157
|
+
const result = await handleRequest(method, path, body, {
|
|
158
|
+
hasToken,
|
|
159
|
+
vaultPassword, // Actual value for verification by handler
|
|
160
|
+
totpCode, // Actual value for verification by handler
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
conn.write(`HTTP/1.1 ${result.status} OK\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(result.body)}`);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
conn.write(`HTTP/1.1 500 Error\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ ok: false, error: 'Internal error' })}`);
|
|
166
|
+
}
|
|
167
|
+
conn.end();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return server;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function startSocketServer(server: ReturnType<typeof createServer>): Promise<void> {
|
|
175
|
+
// Clean up stale socket
|
|
176
|
+
if (existsSync(SOCKET_PATH)) {
|
|
177
|
+
try {
|
|
178
|
+
const { connect } = require('node:net');
|
|
179
|
+
const testConn = connect(SOCKET_PATH);
|
|
180
|
+
await new Promise<void>((resolve, reject) => {
|
|
181
|
+
testConn.on('connect', () => { testConn.destroy(); reject(new Error('Another daemon is running')); });
|
|
182
|
+
testConn.on('error', () => { resolve(); }); // ECONNREFUSED = stale socket
|
|
183
|
+
});
|
|
184
|
+
await unlink(SOCKET_PATH);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if ((err as Error).message === 'Another daemon is running') throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await new Promise<void>((resolve, reject) => {
|
|
191
|
+
server.listen(SOCKET_PATH, () => {
|
|
192
|
+
// Set socket permissions (§9.18)
|
|
193
|
+
require('node:fs').chmodSync(SOCKET_PATH, 0o600);
|
|
194
|
+
resolve();
|
|
195
|
+
});
|
|
196
|
+
server.on('error', reject);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── State File ────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async function writeState(state: HeartbeatState): Promise<void> {
|
|
203
|
+
const tmpPath = STATE_FILE + '.tmp.' + process.pid;
|
|
204
|
+
const fh = await open(tmpPath, 'w');
|
|
205
|
+
try {
|
|
206
|
+
await fh.writeFile(JSON.stringify(state, null, 2));
|
|
207
|
+
if (platform() === 'darwin') {
|
|
208
|
+
await fh.datasync(); // Best-effort on macOS; see §9.18 F_FULLFSYNC caveat
|
|
209
|
+
} else {
|
|
210
|
+
await fh.sync();
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
await fh.close();
|
|
214
|
+
}
|
|
215
|
+
await rename(tmpPath, STATE_FILE);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Signal Handling ───────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function setupSignalHandlers(
|
|
221
|
+
cleanup: () => Promise<void>,
|
|
222
|
+
server: ReturnType<typeof createServer>
|
|
223
|
+
): void {
|
|
224
|
+
let shuttingDown = false;
|
|
225
|
+
|
|
226
|
+
async function shutdown(signal: string): Promise<void> {
|
|
227
|
+
if (shuttingDown) return;
|
|
228
|
+
shuttingDown = true;
|
|
229
|
+
|
|
230
|
+
// 10-second deadline for in-flight requests
|
|
231
|
+
const deadline = setTimeout(() => {
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}, 10000);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
server.close();
|
|
237
|
+
await cleanup();
|
|
238
|
+
await removePidFile();
|
|
239
|
+
await unlink(SOCKET_PATH).catch(() => {});
|
|
240
|
+
clearTimeout(deadline);
|
|
241
|
+
process.exit(0);
|
|
242
|
+
} catch {
|
|
243
|
+
clearTimeout(deadline);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
249
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
250
|
+
|
|
251
|
+
// Disable core dumps — vault key in memory (§9.18)
|
|
252
|
+
try {
|
|
253
|
+
// @ts-expect-error — setrlimit not in Node.js types
|
|
254
|
+
if (process.setrlimit) process.setrlimit('core', { soft: 0, hard: 0 });
|
|
255
|
+
} catch { /* not available on all platforms */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Job Scheduler ─────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
interface ScheduledJob {
|
|
261
|
+
name: string;
|
|
262
|
+
intervalMs: number;
|
|
263
|
+
handler: () => Promise<void>;
|
|
264
|
+
lastRun: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
class JobScheduler {
|
|
268
|
+
private jobs: ScheduledJob[] = [];
|
|
269
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
270
|
+
private lastTick: number = Date.now();
|
|
271
|
+
|
|
272
|
+
add(name: string, intervalMs: number, handler: () => Promise<void>): void {
|
|
273
|
+
this.jobs.push({ name, intervalMs, handler, lastRun: 0 });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
start(): void {
|
|
277
|
+
this.lastTick = Date.now();
|
|
278
|
+
this.timer = setInterval(() => this.tick(), 1000);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
stop(): void {
|
|
282
|
+
if (this.timer) clearInterval(this.timer);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async tick(): Promise<void> {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const delta = now - this.lastTick;
|
|
288
|
+
this.lastTick = now;
|
|
289
|
+
|
|
290
|
+
// Sleep/wake detection (§9.18): if delta > 2x expected (2s), catch-up mode
|
|
291
|
+
if (delta > 2000) {
|
|
292
|
+
// Stagger overdue jobs over 5 minutes, prioritize token refresh
|
|
293
|
+
const overdue = this.jobs.filter(j => now - j.lastRun > j.intervalMs);
|
|
294
|
+
const sorted = overdue.sort((a, b) => {
|
|
295
|
+
if (a.name.includes('token')) return -1;
|
|
296
|
+
if (b.name.includes('token')) return 1;
|
|
297
|
+
return 0;
|
|
298
|
+
});
|
|
299
|
+
for (const job of sorted) {
|
|
300
|
+
try { await job.handler(); } catch { /* logged by handler */ }
|
|
301
|
+
job.lastRun = now;
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Normal tick: run due jobs
|
|
307
|
+
for (const job of this.jobs) {
|
|
308
|
+
if (now - job.lastRun >= job.intervalMs) {
|
|
309
|
+
try { await job.handler(); } catch { /* logged by handler */ }
|
|
310
|
+
job.lastRun = now;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Log Rotation ──────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function createLogger(logPath: string): { log: (msg: string) => void; close: () => void } {
|
|
319
|
+
let stream = createWriteStream(logPath, { flags: 'a' });
|
|
320
|
+
return {
|
|
321
|
+
log(msg: string) {
|
|
322
|
+
stream.write(JSON.stringify({ ts: new Date().toISOString(), msg }) + '\n');
|
|
323
|
+
},
|
|
324
|
+
close() { stream.end(); }
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export {
|
|
329
|
+
writePidFile, checkStalePid, removePidFile,
|
|
330
|
+
generateSessionToken, validateToken,
|
|
331
|
+
createSocketServer, startSocketServer,
|
|
332
|
+
writeState,
|
|
333
|
+
setupSignalHandlers,
|
|
334
|
+
JobScheduler,
|
|
335
|
+
createLogger,
|
|
336
|
+
PID_FILE, SOCKET_PATH, TOKEN_FILE, STATE_FILE, LOG_FILE,
|
|
337
|
+
};
|
|
338
|
+
export type { DaemonState, HeartbeatState, ScheduledJob };
|