claws-code 0.8.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/.claude/commands/claws-auto.md +90 -0
- package/.claude/commands/claws-bin.md +28 -0
- package/.claude/commands/claws-cleanup.md +28 -0
- package/.claude/commands/claws-do.md +82 -0
- package/.claude/commands/claws-fix.md +40 -0
- package/.claude/commands/claws-goal.md +111 -0
- package/.claude/commands/claws-help.md +54 -0
- package/.claude/commands/claws-plan.md +103 -0
- package/.claude/commands/claws-report.md +29 -0
- package/.claude/commands/claws-status.md +37 -0
- package/.claude/commands/claws-update.md +32 -0
- package/.claude/commands/claws.md +64 -0
- package/.claude/rules/claws-default-behavior.md +76 -0
- package/.claude/settings.json +112 -0
- package/.claude/settings.local.json +19 -0
- package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
- package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
- package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
- package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
- package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
- package/CHANGELOG.md +1949 -0
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/bin/cli.js +84 -0
- package/cli.js +223 -0
- package/docs/ARCHITECTURE.md +511 -0
- package/docs/event-protocol.md +588 -0
- package/docs/features.md +562 -0
- package/docs/guide.md +891 -0
- package/docs/index.html +716 -0
- package/docs/protocol.md +323 -0
- package/extension/.vscodeignore +15 -0
- package/extension/CHANGELOG.md +1906 -0
- package/extension/LICENSE +21 -0
- package/extension/README.md +137 -0
- package/extension/docs/features.md +424 -0
- package/extension/docs/protocol.md +197 -0
- package/extension/esbuild.mjs +25 -0
- package/extension/icon.png +0 -0
- package/extension/native/.metadata.json +10 -0
- package/extension/native/node-pty/LICENSE +69 -0
- package/extension/native/node-pty/README.md +165 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
- package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
- package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
- package/extension/native/node-pty/lib/index.js +52 -0
- package/extension/native/node-pty/lib/index.js.map +1 -0
- package/extension/native/node-pty/lib/interfaces.js +7 -0
- package/extension/native/node-pty/lib/interfaces.js.map +1 -0
- package/extension/native/node-pty/lib/shared/conout.js +11 -0
- package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
- package/extension/native/node-pty/lib/terminal.js +190 -0
- package/extension/native/node-pty/lib/terminal.js.map +1 -0
- package/extension/native/node-pty/lib/types.js +7 -0
- package/extension/native/node-pty/lib/types.js.map +1 -0
- package/extension/native/node-pty/lib/unixTerminal.js +346 -0
- package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/utils.js +39 -0
- package/extension/native/node-pty/lib/utils.js.map +1 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
- package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
- package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
- package/extension/native/node-pty/package.json +64 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
- package/extension/package-lock.json +605 -0
- package/extension/package.json +343 -0
- package/extension/scripts/bundle-native.mjs +104 -0
- package/extension/scripts/deploy-dev.mjs +60 -0
- package/extension/src/ansi-strip.ts +52 -0
- package/extension/src/backends/vscode/claws-pty.ts +483 -0
- package/extension/src/backends/vscode/status-bar.ts +99 -0
- package/extension/src/backends/vscode/vscode-backend.ts +282 -0
- package/extension/src/capture-store.ts +125 -0
- package/extension/src/event-log.ts +629 -0
- package/extension/src/event-schemas.ts +478 -0
- package/extension/src/extension.js +492 -0
- package/extension/src/extension.ts +873 -0
- package/extension/src/lifecycle-engine.ts +60 -0
- package/extension/src/lifecycle-rules.ts +171 -0
- package/extension/src/lifecycle-store.ts +506 -0
- package/extension/src/peer-registry.ts +176 -0
- package/extension/src/pipeline-registry.ts +82 -0
- package/extension/src/platform.ts +64 -0
- package/extension/src/protocol.ts +532 -0
- package/extension/src/server-config.ts +98 -0
- package/extension/src/server.ts +2210 -0
- package/extension/src/task-registry.ts +51 -0
- package/extension/src/terminal-backend.ts +211 -0
- package/extension/src/terminal-manager.ts +395 -0
- package/extension/src/topic-registry.ts +70 -0
- package/extension/src/topic-utils.ts +46 -0
- package/extension/src/transport.ts +45 -0
- package/extension/src/uninstall-cleanup.ts +232 -0
- package/extension/src/wave-registry.ts +314 -0
- package/extension/src/websocket-transport.ts +153 -0
- package/extension/tsconfig.json +23 -0
- package/lib/capabilities.js +145 -0
- package/lib/dry-run.js +43 -0
- package/lib/install.js +1018 -0
- package/lib/mcp-setup.js +92 -0
- package/lib/platform.js +240 -0
- package/lib/preflight.js +152 -0
- package/lib/shell-hook.js +343 -0
- package/lib/uninstall.js +162 -0
- package/lib/verify.js +166 -0
- package/mcp_server.js +3529 -0
- package/package.json +48 -0
- package/rules/claws-default-behavior.md +72 -0
- package/scripts/_helpers/atomic-file.mjs +137 -0
- package/scripts/_helpers/fix-repair.js +64 -0
- package/scripts/_helpers/json-safe.mjs +218 -0
- package/scripts/bump-version.sh +84 -0
- package/scripts/codegen/gen-docs.mjs +61 -0
- package/scripts/codegen/gen-json-schema.mjs +62 -0
- package/scripts/codegen/gen-mcp-tools.mjs +358 -0
- package/scripts/codegen/gen-types.mjs +172 -0
- package/scripts/codegen/index.mjs +42 -0
- package/scripts/dev-hooks/check-extension-dirs.js +77 -0
- package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
- package/scripts/dev-hooks/check-stale-main.js +55 -0
- package/scripts/dev-hooks/check-tag-pushed.js +51 -0
- package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
- package/scripts/dev-vsix-install.sh +60 -0
- package/scripts/fix.sh +702 -0
- package/scripts/gen-client-types.mjs +81 -0
- package/scripts/git-hooks/pre-commit +31 -0
- package/scripts/hooks/lifecycle-state.js +61 -0
- package/scripts/hooks/package.json +4 -0
- package/scripts/hooks/post-tool-use-claws.js +292 -0
- package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
- package/scripts/hooks/pre-tool-use-claws.js +206 -0
- package/scripts/hooks/session-start-claws.js +97 -0
- package/scripts/hooks/stop-claws.js +88 -0
- package/scripts/inject-claude-md.js +205 -0
- package/scripts/inject-dev-hooks.js +96 -0
- package/scripts/inject-global-claude-md.js +140 -0
- package/scripts/inject-settings-hooks.js +370 -0
- package/scripts/install.ps1 +146 -0
- package/scripts/install.sh +1729 -0
- package/scripts/monitor-arm-watch.js +155 -0
- package/scripts/rebuild-node-pty.sh +245 -0
- package/scripts/report.sh +232 -0
- package/scripts/shell-hook.fish +164 -0
- package/scripts/shell-hook.ps1 +33 -0
- package/scripts/shell-hook.sh +232 -0
- package/scripts/stream-events.js +399 -0
- package/scripts/terminal-wrapper.sh +36 -0
- package/scripts/test-enforcement.sh +132 -0
- package/scripts/test-install.sh +174 -0
- package/scripts/test-installer-parity.sh +135 -0
- package/scripts/test-template-enforcement.sh +76 -0
- package/scripts/uninstall.sh +143 -0
- package/scripts/update.sh +337 -0
- package/scripts/verify-release.sh +323 -0
- package/scripts/verify-wrapped.sh +194 -0
- package/templates/CLAUDE.global.md +135 -0
- package/templates/CLAUDE.project.md +37 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { FailureCause } from './event-schemas';
|
|
4
|
+
export type { FailureCause };
|
|
5
|
+
|
|
6
|
+
// ─── Lifecycle state model (v0.7.10 — 10-phase, schema v3) ──────────────────
|
|
7
|
+
// Pure CRUD on LifecycleState. Transition rules + gate validators live in
|
|
8
|
+
// lifecycle-rules.ts. Auto-advance logic lives in lifecycle-engine.ts.
|
|
9
|
+
//
|
|
10
|
+
// Rationale: separating state, rules, and engine lets each be tested
|
|
11
|
+
// independently. The store owns persistence; rules are pure functions; the
|
|
12
|
+
// engine subscribes to events and orchestrates state changes.
|
|
13
|
+
|
|
14
|
+
export type Phase =
|
|
15
|
+
| 'SESSION-BOOT'
|
|
16
|
+
| 'PLAN' | 'SPAWN' | 'DEPLOY' | 'OBSERVE'
|
|
17
|
+
| 'RECOVER' | 'HARVEST' | 'CLEANUP' | 'REFLECT'
|
|
18
|
+
| 'SESSION-END' | 'FAILED';
|
|
19
|
+
|
|
20
|
+
export type WorkerMode = 'single' | 'fleet' | 'army';
|
|
21
|
+
export type WorkerStatus = 'spawned' | 'completed' | 'failed' | 'timeout' | 'closed' | 'terminated';
|
|
22
|
+
|
|
23
|
+
export interface SpawnedWorker {
|
|
24
|
+
id: string; // terminal_id
|
|
25
|
+
correlation_id: string; // orchestrator-supplied UUID — race-free monitor key (D)
|
|
26
|
+
name: string;
|
|
27
|
+
spawned_at: string; // ISO
|
|
28
|
+
status: WorkerStatus;
|
|
29
|
+
completed_at?: string; // ISO; set when status leaves 'spawned'
|
|
30
|
+
// ── LH-9 TTL fields (additive, optional for v3 backward-compat) ──────────
|
|
31
|
+
// idle_ms: window of inactivity before idle_timeout close. Reset by PTY
|
|
32
|
+
// activity (log-file mtime sampling) and explicit extendTtl().
|
|
33
|
+
// max_ms: hard ceiling since spawn — never reset by activity.
|
|
34
|
+
// Both default to LifecycleStore.DEFAULT_IDLE_MS / DEFAULT_MAX_MS.
|
|
35
|
+
idle_ms?: number;
|
|
36
|
+
max_ms?: number;
|
|
37
|
+
// last_activity_at: ISO timestamp of last observed PTY mtime / explicit
|
|
38
|
+
// extend. Initialized to spawned_at on registerSpawn. Watchdog computes:
|
|
39
|
+
// idle_expired = now - last_activity_at > idle_ms
|
|
40
|
+
// max_expired = now - spawned_at > max_ms
|
|
41
|
+
last_activity_at?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MonitorRecord {
|
|
45
|
+
terminal_id: string;
|
|
46
|
+
correlation_id: string; // matches SpawnedWorker.correlation_id
|
|
47
|
+
command: string; // verbatim Bash(...) command the orchestrator was instructed to arm
|
|
48
|
+
armed_at: string; // ISO — set by server-side spawn handler atomically with spawn (F)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LifecycleState {
|
|
52
|
+
v: 3; // schema bump for D+F architecture
|
|
53
|
+
phase: Phase;
|
|
54
|
+
phases_completed: Phase[];
|
|
55
|
+
plan: string;
|
|
56
|
+
worker_mode: WorkerMode;
|
|
57
|
+
expected_workers: number;
|
|
58
|
+
spawned_workers: SpawnedWorker[];
|
|
59
|
+
monitors: MonitorRecord[];
|
|
60
|
+
// Backward-compat mirror — v1/v2 consumers expect `workers` field for "is this terminal closed?"
|
|
61
|
+
workers: Array<{ id: string; closed: boolean }>;
|
|
62
|
+
mission_n: number;
|
|
63
|
+
session_started_at: string;
|
|
64
|
+
mission_started_at: string;
|
|
65
|
+
reflect?: string;
|
|
66
|
+
// Set on FAILED transition; preserved across FAILED→PLAN recovery so the
|
|
67
|
+
// orchestrator can read it and apply corrective direction to the new mission.
|
|
68
|
+
failure_cause: FailureCause | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const LEGAL_PHASES = new Set<Phase>([
|
|
72
|
+
'SESSION-BOOT', 'PLAN', 'SPAWN', 'DEPLOY', 'OBSERVE',
|
|
73
|
+
'RECOVER', 'HARVEST', 'CLEANUP', 'REFLECT', 'SESSION-END', 'FAILED',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const VALID_WORKER_MODES = new Set<WorkerMode>(['single', 'fleet', 'army']);
|
|
77
|
+
|
|
78
|
+
// LH-9 TTL defaults — chosen against observed Claude Code TUI behavior.
|
|
79
|
+
// idle: 10 min covers all measured thinking-pauses with 2x margin (longest
|
|
80
|
+
// observed pause was ~5 min during heavy planning).
|
|
81
|
+
// max: 4 h covers the longest legitimate workload (40-min audit fleet) with
|
|
82
|
+
// 6x margin. Anything longer should not be a Claude Code worker.
|
|
83
|
+
// Both are configurable per-spawn via registerSpawn opts.
|
|
84
|
+
export const DEFAULT_IDLE_MS = 600_000; // 10 min
|
|
85
|
+
export const DEFAULT_MAX_MS = 14_400_000; // 4 h
|
|
86
|
+
|
|
87
|
+
export class LifecycleStore {
|
|
88
|
+
private state: LifecycleState | null = null;
|
|
89
|
+
private readonly statePath: string;
|
|
90
|
+
private readonly sessionStartedAt: string;
|
|
91
|
+
|
|
92
|
+
constructor(workspaceRoot: string) {
|
|
93
|
+
this.statePath = path.join(workspaceRoot, '.claws', 'lifecycle-state.json');
|
|
94
|
+
this.sessionStartedAt = new Date().toISOString();
|
|
95
|
+
this.loadFromDisk();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when in a mission cycle (PLAN..CLEANUP). False at SESSION-BOOT, REFLECT, SESSION-END, or null. */
|
|
99
|
+
hasPlan(): boolean {
|
|
100
|
+
if (!this.state) return false;
|
|
101
|
+
const p = this.state.phase;
|
|
102
|
+
return p !== 'SESSION-BOOT' && p !== 'REFLECT' && p !== 'SESSION-END';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Returns current state, or null if no SESSION-BOOT yet. */
|
|
106
|
+
snapshot(): LifecycleState | null { return this.state; }
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initialize at SESSION-BOOT. Idempotent: subsequent calls return existing state.
|
|
110
|
+
* Auto-fired by server constructor; can also be called explicitly by session-start hook.
|
|
111
|
+
*/
|
|
112
|
+
bootSession(): LifecycleState {
|
|
113
|
+
if (this.state !== null) return this.state;
|
|
114
|
+
this.state = {
|
|
115
|
+
v: 3,
|
|
116
|
+
phase: 'SESSION-BOOT',
|
|
117
|
+
phases_completed: ['SESSION-BOOT'],
|
|
118
|
+
plan: '',
|
|
119
|
+
worker_mode: 'single', // placeholder — overwritten by plan()
|
|
120
|
+
expected_workers: 0,
|
|
121
|
+
spawned_workers: [],
|
|
122
|
+
monitors: [],
|
|
123
|
+
workers: [],
|
|
124
|
+
mission_n: 0,
|
|
125
|
+
session_started_at: this.sessionStartedAt,
|
|
126
|
+
mission_started_at: '',
|
|
127
|
+
failure_cause: null,
|
|
128
|
+
};
|
|
129
|
+
this.flushToDisk();
|
|
130
|
+
return this.state;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Start mission cycle at PLAN phase. workerMode + expectedWorkers REQUIRED.
|
|
135
|
+
* Re-entry from REFLECT starts cycle N+1; otherwise idempotent within active cycle.
|
|
136
|
+
*/
|
|
137
|
+
plan(planText: string, workerMode: WorkerMode, expectedWorkers: number): LifecycleState {
|
|
138
|
+
if (!planText.trim()) throw new Error('lifecycle:plan-empty');
|
|
139
|
+
if (!VALID_WORKER_MODES.has(workerMode)) {
|
|
140
|
+
throw new Error(`lifecycle:invalid-worker-mode — must be single|fleet|army, got: ${workerMode}`);
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isInteger(expectedWorkers) || expectedWorkers < 1) {
|
|
143
|
+
throw new Error('lifecycle:invalid-expected-workers — must be positive integer');
|
|
144
|
+
}
|
|
145
|
+
if (this.state === null) this.bootSession();
|
|
146
|
+
const isRecoveringFromFailed = this.state!.phase === 'FAILED';
|
|
147
|
+
const inActiveMission = this.state!.phase !== 'SESSION-BOOT'
|
|
148
|
+
&& this.state!.phase !== 'REFLECT'
|
|
149
|
+
&& this.state!.phase !== 'SESSION-END'
|
|
150
|
+
&& !isRecoveringFromFailed; // FAILED is recoverable — allow re-plan
|
|
151
|
+
if (inActiveMission) {
|
|
152
|
+
// Idempotent within active mission — return existing state unchanged
|
|
153
|
+
return this.state!;
|
|
154
|
+
}
|
|
155
|
+
const nextMissionN = (this.state!.phase === 'REFLECT' || isRecoveringFromFailed)
|
|
156
|
+
? this.state!.mission_n + 1
|
|
157
|
+
: 1;
|
|
158
|
+
// On FAILED recovery: preserve failure_cause so orchestrator can reference
|
|
159
|
+
// it after re-plan. All worker/monitor arrays start fresh.
|
|
160
|
+
const preservedFailureCause = isRecoveringFromFailed ? this.state!.failure_cause : null;
|
|
161
|
+
this.state = {
|
|
162
|
+
...this.state!,
|
|
163
|
+
v: 3,
|
|
164
|
+
phase: 'PLAN',
|
|
165
|
+
phases_completed: this.state!.phases_completed.includes('PLAN')
|
|
166
|
+
? this.state!.phases_completed
|
|
167
|
+
: [...this.state!.phases_completed, 'PLAN'],
|
|
168
|
+
plan: planText.trim(),
|
|
169
|
+
worker_mode: workerMode,
|
|
170
|
+
expected_workers: expectedWorkers,
|
|
171
|
+
spawned_workers: [],
|
|
172
|
+
monitors: [],
|
|
173
|
+
workers: [],
|
|
174
|
+
mission_n: nextMissionN,
|
|
175
|
+
mission_started_at: new Date().toISOString(),
|
|
176
|
+
reflect: undefined,
|
|
177
|
+
failure_cause: preservedFailureCause,
|
|
178
|
+
};
|
|
179
|
+
this.flushToDisk();
|
|
180
|
+
return this.state;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Convenience: alias for setPhase(). Provided for backward-compat with v1/v2
|
|
185
|
+
* callers. New code should use setPhase + caller-side validation via lifecycle-rules.
|
|
186
|
+
*/
|
|
187
|
+
advance(toPhase: Phase, _reason?: string): LifecycleState {
|
|
188
|
+
return this.setPhase(toPhase);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Set phase directly. NO transition validation here — that's the engine's job
|
|
193
|
+
* (which calls canTransition() from lifecycle-rules.ts before calling this).
|
|
194
|
+
* Use this only when you've already validated the transition.
|
|
195
|
+
*
|
|
196
|
+
* When transitioning to FAILED, pass opts.failure_cause to attach structured
|
|
197
|
+
* context the orchestrator can read after recovery via plan().
|
|
198
|
+
*/
|
|
199
|
+
setPhase(toPhase: Phase, opts?: { failure_cause?: FailureCause }): LifecycleState {
|
|
200
|
+
if (!this.state) throw new Error('lifecycle:no-state');
|
|
201
|
+
if (this.state.phase === toPhase) return this.state;
|
|
202
|
+
const phases_completed = this.state.phases_completed.includes(toPhase)
|
|
203
|
+
? this.state.phases_completed
|
|
204
|
+
: [...this.state.phases_completed, toPhase];
|
|
205
|
+
const failure_cause = toPhase === 'FAILED' && opts?.failure_cause
|
|
206
|
+
? opts.failure_cause
|
|
207
|
+
: this.state.failure_cause;
|
|
208
|
+
this.state = { ...this.state, phase: toPhase, phases_completed, failure_cause };
|
|
209
|
+
this.flushToDisk();
|
|
210
|
+
return this.state;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Register a newly-spawned worker. Called atomically by server-side spawn-class
|
|
215
|
+
* tool handler (claws_create / claws_worker / claws_fleet / claws_dispatch_subworker).
|
|
216
|
+
* correlation_id is the orchestrator-supplied UUID (D) used to match worker.* events.
|
|
217
|
+
*/
|
|
218
|
+
registerSpawn(
|
|
219
|
+
terminalId: string,
|
|
220
|
+
correlationId: string,
|
|
221
|
+
name: string,
|
|
222
|
+
opts?: { idle_ms?: number; max_ms?: number },
|
|
223
|
+
): SpawnedWorker {
|
|
224
|
+
if (!this.state) throw new Error('lifecycle:no-state');
|
|
225
|
+
if (!correlationId || !correlationId.trim()) {
|
|
226
|
+
throw new Error('lifecycle:correlation-id-required — orchestrator must supply correlation_id for race-free monitor');
|
|
227
|
+
}
|
|
228
|
+
const idx = this.state.spawned_workers.findIndex(w => w.id === terminalId);
|
|
229
|
+
const existing = idx === -1 ? null : this.state.spawned_workers[idx];
|
|
230
|
+
if (existing) {
|
|
231
|
+
// Idempotent: same id+corrId on a still-spawned worker returns existing.
|
|
232
|
+
if (existing.status === 'spawned' && existing.correlation_id === correlationId) {
|
|
233
|
+
return existing;
|
|
234
|
+
}
|
|
235
|
+
// LH-9: An existing entry with a different corrId is a conflict ONLY if
|
|
236
|
+
// the worker is still active. Closed/completed/failed/timeout slots
|
|
237
|
+
// are historical — VS Code reload restarts the terminal id counter, so
|
|
238
|
+
// a fresh spawn legitimately reuses a stale id. Block only on a live
|
|
239
|
+
// collision (the prior worker is still running).
|
|
240
|
+
if (existing.status === 'spawned' && existing.correlation_id !== correlationId) {
|
|
241
|
+
throw new Error(`lifecycle:correlation-id-conflict — terminal ${terminalId} already registered with different corrId`);
|
|
242
|
+
}
|
|
243
|
+
// existing.status is non-spawned → fall through, overwrite below.
|
|
244
|
+
}
|
|
245
|
+
const spawnedAt = new Date().toISOString();
|
|
246
|
+
const idleMs = opts?.idle_ms ?? DEFAULT_IDLE_MS;
|
|
247
|
+
const maxMs = opts?.max_ms ?? DEFAULT_MAX_MS;
|
|
248
|
+
const rec: SpawnedWorker = {
|
|
249
|
+
id: terminalId,
|
|
250
|
+
correlation_id: correlationId,
|
|
251
|
+
name,
|
|
252
|
+
spawned_at: spawnedAt,
|
|
253
|
+
status: 'spawned',
|
|
254
|
+
idle_ms: idleMs,
|
|
255
|
+
max_ms: maxMs,
|
|
256
|
+
last_activity_at: spawnedAt,
|
|
257
|
+
};
|
|
258
|
+
let nextSpawned: SpawnedWorker[];
|
|
259
|
+
let nextWorkers: Array<{ id: string; closed: boolean }>;
|
|
260
|
+
if (idx === -1) {
|
|
261
|
+
nextSpawned = [...this.state.spawned_workers, rec];
|
|
262
|
+
nextWorkers = [...this.state.workers, { id: terminalId, closed: false }];
|
|
263
|
+
} else {
|
|
264
|
+
nextSpawned = [...this.state.spawned_workers];
|
|
265
|
+
nextSpawned[idx] = rec;
|
|
266
|
+
const wIdx = this.state.workers.findIndex(w => w.id === terminalId);
|
|
267
|
+
if (wIdx === -1) {
|
|
268
|
+
nextWorkers = [...this.state.workers, { id: terminalId, closed: false }];
|
|
269
|
+
} else {
|
|
270
|
+
nextWorkers = [...this.state.workers];
|
|
271
|
+
nextWorkers[wIdx] = { id: terminalId, closed: false };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
this.state = { ...this.state, spawned_workers: nextSpawned, workers: nextWorkers };
|
|
275
|
+
this.flushToDisk();
|
|
276
|
+
return rec;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* LH-9: Mark fresh activity for a worker. Resets the idle TTL window.
|
|
281
|
+
* Called by the watchdog when PTY log-file mtime advances or when an
|
|
282
|
+
* orchestrator MCP call touches the terminal. Cheap — only flushes to
|
|
283
|
+
* disk when the change is meaningful (>5s since last persist).
|
|
284
|
+
* Returns the new last_activity_at, or null if worker is unknown/closed.
|
|
285
|
+
*/
|
|
286
|
+
markActivity(terminalId: string, atIso?: string): string | null {
|
|
287
|
+
if (!this.state) return null;
|
|
288
|
+
const idx = this.state.spawned_workers.findIndex(w => w.id === terminalId);
|
|
289
|
+
if (idx === -1) return null;
|
|
290
|
+
const cur = this.state.spawned_workers[idx];
|
|
291
|
+
if (cur.status !== 'spawned') return null;
|
|
292
|
+
const at = atIso ?? new Date().toISOString();
|
|
293
|
+
// Throttle disk flushes to avoid IO storm — in-memory state always
|
|
294
|
+
// reflects truth, but only persist when the gap is >5s. Watchdog reads
|
|
295
|
+
// in-memory snapshot, so durability lag is fine here.
|
|
296
|
+
const lastIso = cur.last_activity_at ?? cur.spawned_at;
|
|
297
|
+
const lastMs = Date.parse(lastIso);
|
|
298
|
+
const atMs = Date.parse(at);
|
|
299
|
+
const newSpawned = [...this.state.spawned_workers];
|
|
300
|
+
newSpawned[idx] = { ...cur, last_activity_at: at };
|
|
301
|
+
this.state = { ...this.state, spawned_workers: newSpawned };
|
|
302
|
+
if (atMs - lastMs >= 5000) {
|
|
303
|
+
this.flushToDisk();
|
|
304
|
+
}
|
|
305
|
+
return at;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* LH-9: Atomically extend a worker's idle TTL by addMs. Used by orchestrator
|
|
310
|
+
* for long-running missions that exceed default idle window. Returns the
|
|
311
|
+
* new last_activity_at on success, or null if the worker is unknown or
|
|
312
|
+
* already non-spawned (lost race with watchdog).
|
|
313
|
+
*/
|
|
314
|
+
extendTtl(terminalId: string, addMs: number): string | null {
|
|
315
|
+
if (!this.state) return null;
|
|
316
|
+
if (!Number.isFinite(addMs) || addMs <= 0) return null;
|
|
317
|
+
const idx = this.state.spawned_workers.findIndex(w => w.id === terminalId);
|
|
318
|
+
if (idx === -1) return null;
|
|
319
|
+
const cur = this.state.spawned_workers[idx];
|
|
320
|
+
if (cur.status !== 'spawned') return null;
|
|
321
|
+
// Push last_activity_at forward by addMs from now, equivalent to refreshing
|
|
322
|
+
// and adding a one-shot grace window. Caller-supplied addMs is the grace.
|
|
323
|
+
const newIso = new Date(Date.now() + addMs).toISOString();
|
|
324
|
+
const newSpawned = [...this.state.spawned_workers];
|
|
325
|
+
newSpawned[idx] = { ...cur, last_activity_at: newIso };
|
|
326
|
+
this.state = { ...this.state, spawned_workers: newSpawned };
|
|
327
|
+
this.flushToDisk();
|
|
328
|
+
return newIso;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* LH-9/LH-10: Boot reconciliation — given the live terminal IDs from
|
|
333
|
+
* TerminalManager, mark every spawned_worker NOT in liveIds as closed, AND
|
|
334
|
+
* drop every monitors[] entry whose terminal_id is not live. Self-heals
|
|
335
|
+
* stale state from extension reload / VS Code crash. Returns both the list
|
|
336
|
+
* of worker IDs reconciled and the list of monitor terminal_ids dropped.
|
|
337
|
+
*/
|
|
338
|
+
reconcileWithLiveTerminals(liveIds: ReadonlySet<string>): { workersClosed: string[]; monitorsDropped: string[] } {
|
|
339
|
+
if (!this.state) return { workersClosed: [], monitorsDropped: [] };
|
|
340
|
+
const workersClosed: string[] = [];
|
|
341
|
+
const newSpawned = this.state.spawned_workers.map(w => {
|
|
342
|
+
if (w.status !== 'spawned' || liveIds.has(w.id)) return w;
|
|
343
|
+
workersClosed.push(w.id);
|
|
344
|
+
return { ...w, status: 'closed' as WorkerStatus, completed_at: new Date().toISOString() };
|
|
345
|
+
});
|
|
346
|
+
// LH-10: also drop orphan monitor records (mirrors spawned_workers reconcile)
|
|
347
|
+
const newMonitors = this.state.monitors.filter(m => liveIds.has(m.terminal_id));
|
|
348
|
+
const monitorsDropped = this.state.monitors
|
|
349
|
+
.filter(m => !liveIds.has(m.terminal_id))
|
|
350
|
+
.map(m => m.terminal_id);
|
|
351
|
+
if (workersClosed.length === 0 && monitorsDropped.length === 0) {
|
|
352
|
+
return { workersClosed: [], monitorsDropped: [] };
|
|
353
|
+
}
|
|
354
|
+
const reconciledSet = new Set(workersClosed);
|
|
355
|
+
const newWorkers = this.state.workers.map(w =>
|
|
356
|
+
reconciledSet.has(w.id) ? { ...w, closed: true } : w
|
|
357
|
+
);
|
|
358
|
+
this.state = { ...this.state, spawned_workers: newSpawned, workers: newWorkers, monitors: newMonitors };
|
|
359
|
+
this.flushToDisk();
|
|
360
|
+
return { workersClosed, monitorsDropped };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* LH-10: Remove the monitor record for a terminal. Idempotent — returns
|
|
365
|
+
* true if a record was found and removed, false if no match. Flushes to
|
|
366
|
+
* disk only when a record was actually removed.
|
|
367
|
+
*/
|
|
368
|
+
removeMonitorByTerminalId(terminalId: string): boolean {
|
|
369
|
+
if (!this.state) return false;
|
|
370
|
+
const before = this.state.monitors.length;
|
|
371
|
+
const filtered = this.state.monitors.filter(m => m.terminal_id !== terminalId);
|
|
372
|
+
if (filtered.length === before) return false;
|
|
373
|
+
this.state = { ...this.state, monitors: filtered };
|
|
374
|
+
this.flushToDisk();
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* LH-9: Watchdog scan. Returns workers whose idle or max window has
|
|
380
|
+
* elapsed. Read-only — caller is responsible for closing each via
|
|
381
|
+
* terminalManager.close(id, reason). Status check is inline so a worker
|
|
382
|
+
* already in-flight to closed (race) is not double-emitted.
|
|
383
|
+
*/
|
|
384
|
+
findExpiredWorkers(nowMs: number = Date.now()): Array<{ id: string; reason: 'idle_timeout' | 'ttl_max' }> {
|
|
385
|
+
if (!this.state) return [];
|
|
386
|
+
const out: Array<{ id: string; reason: 'idle_timeout' | 'ttl_max' }> = [];
|
|
387
|
+
for (const w of this.state.spawned_workers) {
|
|
388
|
+
if (w.status !== 'spawned') continue;
|
|
389
|
+
const spawnedMs = Date.parse(w.spawned_at);
|
|
390
|
+
const maxMs = w.max_ms ?? DEFAULT_MAX_MS;
|
|
391
|
+
if (Number.isFinite(spawnedMs) && nowMs - spawnedMs > maxMs) {
|
|
392
|
+
out.push({ id: w.id, reason: 'ttl_max' });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const idleMs = w.idle_ms ?? DEFAULT_IDLE_MS;
|
|
396
|
+
const lastActivityIso = w.last_activity_at ?? w.spawned_at;
|
|
397
|
+
const lastMs = Date.parse(lastActivityIso);
|
|
398
|
+
if (Number.isFinite(lastMs) && nowMs - lastMs > idleMs) {
|
|
399
|
+
out.push({ id: w.id, reason: 'idle_timeout' });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Register a per-worker monitor. Called atomically by server-side spawn handler
|
|
407
|
+
* RIGHT AFTER registerSpawn. The orchestrator pre-armed the watcher (D), so by
|
|
408
|
+
* the time spawn returns, the monitor record is already in place (F).
|
|
409
|
+
*/
|
|
410
|
+
registerMonitor(terminalId: string, correlationId: string, command: string): MonitorRecord {
|
|
411
|
+
if (!this.state) throw new Error('lifecycle:no-state');
|
|
412
|
+
const filtered = this.state.monitors.filter(m => m.terminal_id !== terminalId);
|
|
413
|
+
const rec: MonitorRecord = {
|
|
414
|
+
terminal_id: terminalId,
|
|
415
|
+
correlation_id: correlationId,
|
|
416
|
+
command,
|
|
417
|
+
armed_at: new Date().toISOString(),
|
|
418
|
+
};
|
|
419
|
+
this.state = { ...this.state, monitors: [...filtered, rec] };
|
|
420
|
+
this.flushToDisk();
|
|
421
|
+
return rec;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Update a worker's status. Called by detach watcher when worker reaches terminal state.
|
|
426
|
+
*/
|
|
427
|
+
markWorkerStatus(terminalId: string, status: WorkerStatus): SpawnedWorker | null {
|
|
428
|
+
if (!this.state) return null;
|
|
429
|
+
const idx = this.state.spawned_workers.findIndex(w => w.id === terminalId);
|
|
430
|
+
if (idx === -1) return null;
|
|
431
|
+
const updated: SpawnedWorker = {
|
|
432
|
+
...this.state.spawned_workers[idx],
|
|
433
|
+
status,
|
|
434
|
+
...(status !== 'spawned' ? { completed_at: new Date().toISOString() } : {}),
|
|
435
|
+
};
|
|
436
|
+
const newSpawned = [...this.state.spawned_workers];
|
|
437
|
+
newSpawned[idx] = updated;
|
|
438
|
+
const newWorkers = this.state.workers.map(w =>
|
|
439
|
+
w.id === terminalId ? { ...w, closed: status === 'closed' } : w
|
|
440
|
+
);
|
|
441
|
+
this.state = { ...this.state, spawned_workers: newSpawned, workers: newWorkers };
|
|
442
|
+
this.flushToDisk();
|
|
443
|
+
return updated;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Persist reflect text + transition to REFLECT. Must already have set phase to REFLECT
|
|
448
|
+
* (or be transitioning from CLEANUP — the engine handles validation).
|
|
449
|
+
*/
|
|
450
|
+
reflect(reflectText: string): LifecycleState {
|
|
451
|
+
if (!reflectText.trim()) throw new Error('lifecycle:reflect-empty');
|
|
452
|
+
if (!this.state) throw new Error('lifecycle:no-state');
|
|
453
|
+
this.state = { ...this.state, phase: 'REFLECT', reflect: reflectText.trim() };
|
|
454
|
+
if (!this.state.phases_completed.includes('REFLECT')) {
|
|
455
|
+
this.state.phases_completed = [...this.state.phases_completed, 'REFLECT'];
|
|
456
|
+
}
|
|
457
|
+
this.flushToDisk();
|
|
458
|
+
return this.state;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
loadFromDisk(): void {
|
|
462
|
+
try {
|
|
463
|
+
if (!fs.existsSync(this.statePath)) return;
|
|
464
|
+
const raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8')) as unknown;
|
|
465
|
+
if (this.isValidV3(raw)) {
|
|
466
|
+
// Back-fill failure_cause for state files written before T9
|
|
467
|
+
if (raw.failure_cause === undefined) {
|
|
468
|
+
(raw as unknown as Record<string, unknown>)['failure_cause'] = null;
|
|
469
|
+
}
|
|
470
|
+
this.state = raw;
|
|
471
|
+
}
|
|
472
|
+
// v1/v2 not auto-migrated — schema is breaking. Old state files start fresh.
|
|
473
|
+
// Documented in CHANGELOG: "v0.7.10 lifecycle schema v3 — old state files discarded".
|
|
474
|
+
} catch { /* invalid file — start fresh */ }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
flushToDisk(): void {
|
|
478
|
+
if (!this.state) return;
|
|
479
|
+
const dir = path.dirname(this.statePath);
|
|
480
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
481
|
+
const tmp = this.statePath + '.tmp';
|
|
482
|
+
const fd = fs.openSync(tmp, 'w');
|
|
483
|
+
try {
|
|
484
|
+
fs.writeSync(fd, JSON.stringify(this.state, null, 2) + '\n');
|
|
485
|
+
fs.fsyncSync(fd); // M-43: fsyncSync before renameSync (parity with M-29)
|
|
486
|
+
} finally {
|
|
487
|
+
fs.closeSync(fd);
|
|
488
|
+
}
|
|
489
|
+
fs.renameSync(tmp, this.statePath);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private isValidV3(raw: unknown): raw is LifecycleState {
|
|
493
|
+
if (!raw || typeof raw !== 'object') return false;
|
|
494
|
+
const s = raw as Record<string, unknown>;
|
|
495
|
+
return (
|
|
496
|
+
s['v'] === 3 &&
|
|
497
|
+
LEGAL_PHASES.has(s['phase'] as Phase) &&
|
|
498
|
+
typeof s['plan'] === 'string' &&
|
|
499
|
+
Array.isArray(s['phases_completed']) &&
|
|
500
|
+
Array.isArray(s['spawned_workers']) &&
|
|
501
|
+
Array.isArray(s['monitors']) &&
|
|
502
|
+
typeof s['mission_n'] === 'number' &&
|
|
503
|
+
VALID_WORKER_MODES.has(s['worker_mode'] as WorkerMode)
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// claws/2 peer registry primitives.
|
|
2
|
+
//
|
|
3
|
+
// The registry holds one `PeerConnection` per active socket that has
|
|
4
|
+
// completed the `hello` handshake. It is owned by the `ClawsServer` and
|
|
5
|
+
// cleared on `stop()`. matchTopic is defined in topic-utils.ts and
|
|
6
|
+
// re-exported here for backward compatibility with existing callers.
|
|
7
|
+
|
|
8
|
+
import * as net from 'net';
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
export { matchTopic } from './topic-utils';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Role declared by a peer during the `hello` handshake. The role gates
|
|
15
|
+
* which commands a peer may issue (e.g. only an orchestrator may dispatch
|
|
16
|
+
* tasks). Exactly one `orchestrator` is permitted per server instance.
|
|
17
|
+
*/
|
|
18
|
+
export type ClawsRole = 'orchestrator' | 'worker' | 'observer';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Live record of a peer that has completed `hello`. Keyed in the server's
|
|
22
|
+
* `peers` map by `peerId`. The `socket` reference is the raw net.Socket
|
|
23
|
+
* held open for server-push writes; on disconnect the server must remove
|
|
24
|
+
* the record from the registry and the subscription index.
|
|
25
|
+
*/
|
|
26
|
+
export interface PeerConnection {
|
|
27
|
+
/** Allocated peerId, format `p_` + 6 lowercase hex chars OR `fp_` + 12 hex for fingerprinted peers. */
|
|
28
|
+
peerId: string;
|
|
29
|
+
/** Role as declared in the `hello` frame. */
|
|
30
|
+
role: ClawsRole;
|
|
31
|
+
/** Human-friendly name from the `hello` frame (used in logs / UI). */
|
|
32
|
+
peerName: string;
|
|
33
|
+
/** Optional terminal id the peer is bound to (workers attached to a pty). */
|
|
34
|
+
terminalId?: string;
|
|
35
|
+
/** Capability strings the peer advertised. May be empty. */
|
|
36
|
+
capabilities: string[];
|
|
37
|
+
/** Wave ID this peer belongs to (set when hello includes waveId). */
|
|
38
|
+
waveId?: string;
|
|
39
|
+
/** Sub-worker role within the wave (set when hello includes subWorkerRole). */
|
|
40
|
+
subWorkerRole?: string;
|
|
41
|
+
/** Live socket — used exclusively by the server for push frames. */
|
|
42
|
+
socket: net.Socket;
|
|
43
|
+
/** subscriptionId → topicPattern. Patterns may contain `*` / `**`. */
|
|
44
|
+
subscriptions: Map<string, string>;
|
|
45
|
+
/** Monotonic timestamp updated on every command from this peer. */
|
|
46
|
+
lastSeen: number;
|
|
47
|
+
/** Timestamp when the `hello` was accepted. */
|
|
48
|
+
connectedAt: number;
|
|
49
|
+
/**
|
|
50
|
+
* Stable 12-hex fingerprint derived from sha256(peerName+role+instanceNonce).
|
|
51
|
+
* Present only when the peer supplied `instanceNonce` in their hello frame.
|
|
52
|
+
* Used to restore subscriptions and tasks on reconnect.
|
|
53
|
+
*/
|
|
54
|
+
fingerprint?: string;
|
|
55
|
+
/** AC-1: lifecycle correlation id supplied by the peer at hello time (from CLAWS_TERMINAL_CORR_ID env).
|
|
56
|
+
* Used for event-driven boot detection and audit-trail correlation. */
|
|
57
|
+
correlationId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tombstone stored when a fingerprinted peer disconnects. Allows the server
|
|
62
|
+
* to restore subscriptions and re-bind orphaned tasks on reconnect.
|
|
63
|
+
*/
|
|
64
|
+
export interface DisconnectedPeer {
|
|
65
|
+
peerId: string;
|
|
66
|
+
fingerprint: string;
|
|
67
|
+
role: ClawsRole;
|
|
68
|
+
peerName: string;
|
|
69
|
+
capabilities: string[];
|
|
70
|
+
/** subscriptionId → topicPattern snapshot from the moment of disconnect. */
|
|
71
|
+
subscriptions: Map<string, string>;
|
|
72
|
+
disconnectedAt: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Allocate a wire-format peerId for the Nth peer registered in a server's
|
|
77
|
+
* lifetime. Called by `ClawsServer` with a monotonically increasing `seq`.
|
|
78
|
+
* Format is stable: `p_` followed by `seq.toString(16)` zero-padded to 6
|
|
79
|
+
* hex chars — unique up to 16,777,215 peers per server run, which far
|
|
80
|
+
* exceeds any realistic fleet.
|
|
81
|
+
*/
|
|
82
|
+
export function allocPeerId(seq: number): string {
|
|
83
|
+
return 'p_' + seq.toString(16).padStart(6, '0');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Derive a stable 12-hex peerId component from the peer's identity tuple.
|
|
88
|
+
* The result is the first 12 hex chars (6 bytes) of sha256(peerName+role+nonce).
|
|
89
|
+
* Callers prefix with `fp_` to produce the wire peerId: `fp_<fingerprint>`.
|
|
90
|
+
*/
|
|
91
|
+
export function fingerprintPeer(peerName: string, role: string, nonce: string): string {
|
|
92
|
+
return crypto
|
|
93
|
+
.createHash('sha256')
|
|
94
|
+
.update(peerName + '\x00' + role + '\x00' + nonce)
|
|
95
|
+
.digest('hex')
|
|
96
|
+
.slice(0, 12);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Bug-6 Layer 2 — tracks which Monitor peers have declared a monitorCorrelationId
|
|
101
|
+
* at hello time. armedCorrelations links corrId → peerId; peerToCorr is the
|
|
102
|
+
* reverse index used to clean up claims on peer disconnect.
|
|
103
|
+
*
|
|
104
|
+
* All four methods are O(1). The registry is never cleared between sessions
|
|
105
|
+
* because it lives for the duration of the server instance.
|
|
106
|
+
*/
|
|
107
|
+
export class PeerRegistry {
|
|
108
|
+
private readonly armedCorrelations = new Map<string, string>(); // corrId → peerId
|
|
109
|
+
private readonly peerToCorr = new Map<string, string>(); // peerId → corrId
|
|
110
|
+
private readonly pendingArms = new Set<string>(); // corrIds: intent registered, execution not yet completed
|
|
111
|
+
private traceLogPath: string | null = null;
|
|
112
|
+
|
|
113
|
+
/** Set the path for the peer-registry trace log (called once at server start). */
|
|
114
|
+
setTraceLogPath(p: string): void { this.traceLogPath = p; }
|
|
115
|
+
|
|
116
|
+
/** Append a structured trace line to .claws/peer-registry-trace.log. */
|
|
117
|
+
private trace(event: string, peerId: string, extra?: Record<string, unknown>): void {
|
|
118
|
+
if (!this.traceLogPath) return;
|
|
119
|
+
try {
|
|
120
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), event, peerId, ...extra }) + '\n';
|
|
121
|
+
fs.appendFileSync(this.traceLogPath, line);
|
|
122
|
+
} catch { /* non-fatal */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Notify registry that a peer was registered (called from ClawsServer hello handler). */
|
|
126
|
+
notifyRegister(peerId: string, role: string, extra?: { fingerprint?: string; monitorCorrelationId?: string }): void {
|
|
127
|
+
this.trace('register', peerId, { role, ...extra });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Notify registry that a peer was unregistered (called from ClawsServer handleDisconnect). */
|
|
131
|
+
notifyUnregister(peerId: string, reason: string): void {
|
|
132
|
+
this.trace('unregister', peerId, { reason });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Record that peerId is the Monitor process for correlationId. One peer → one claim. */
|
|
136
|
+
recordMonitorClaim(peerId: string, correlationId: string): void {
|
|
137
|
+
this.removeMonitorClaim(peerId);
|
|
138
|
+
this.armedCorrelations.set(correlationId, peerId);
|
|
139
|
+
this.peerToCorr.set(peerId, correlationId);
|
|
140
|
+
this.pendingArms.delete(correlationId); // graduation from intent to execution
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Returns true if any live peer has declared a claim for this correlationId. */
|
|
144
|
+
isCorrIdArmed(correlationId: string): boolean {
|
|
145
|
+
return this.armedCorrelations.has(correlationId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Remove any claim held by peerId (called on peer disconnect). Does NOT touch pendingArms. */
|
|
149
|
+
removeMonitorClaim(peerId: string): void {
|
|
150
|
+
const corrId = this.peerToCorr.get(peerId);
|
|
151
|
+
if (corrId !== undefined) {
|
|
152
|
+
this.armedCorrelations.delete(corrId);
|
|
153
|
+
this.peerToCorr.delete(peerId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Return the peerId that claimed corrId, or undefined if unclaimed. */
|
|
158
|
+
getArmedPeerForCorrId(correlationId: string): string | undefined {
|
|
159
|
+
return this.armedCorrelations.get(correlationId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Register intent to arm a monitor for corrId. Set server-side at spawn time. */
|
|
163
|
+
registerArmIntent(correlationId: string): void {
|
|
164
|
+
this.pendingArms.add(correlationId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Remove intent without graduation (used only for cleanup / testing). */
|
|
168
|
+
removeArmIntent(correlationId: string): void {
|
|
169
|
+
this.pendingArms.delete(correlationId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Returns true if intent was registered but execution (hello-claim) has not completed. */
|
|
173
|
+
isCorrIdPending(correlationId: string): boolean {
|
|
174
|
+
return this.pendingArms.has(correlationId);
|
|
175
|
+
}
|
|
176
|
+
}
|