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.
Files changed (180) hide show
  1. package/.claude/commands/claws-auto.md +90 -0
  2. package/.claude/commands/claws-bin.md +28 -0
  3. package/.claude/commands/claws-cleanup.md +28 -0
  4. package/.claude/commands/claws-do.md +82 -0
  5. package/.claude/commands/claws-fix.md +40 -0
  6. package/.claude/commands/claws-goal.md +111 -0
  7. package/.claude/commands/claws-help.md +54 -0
  8. package/.claude/commands/claws-plan.md +103 -0
  9. package/.claude/commands/claws-report.md +29 -0
  10. package/.claude/commands/claws-status.md +37 -0
  11. package/.claude/commands/claws-update.md +32 -0
  12. package/.claude/commands/claws.md +64 -0
  13. package/.claude/rules/claws-default-behavior.md +76 -0
  14. package/.claude/settings.json +112 -0
  15. package/.claude/settings.local.json +19 -0
  16. package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
  17. package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
  18. package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
  19. package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
  20. package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
  21. package/CHANGELOG.md +1949 -0
  22. package/LICENSE +21 -0
  23. package/README.md +420 -0
  24. package/bin/cli.js +84 -0
  25. package/cli.js +223 -0
  26. package/docs/ARCHITECTURE.md +511 -0
  27. package/docs/event-protocol.md +588 -0
  28. package/docs/features.md +562 -0
  29. package/docs/guide.md +891 -0
  30. package/docs/index.html +716 -0
  31. package/docs/protocol.md +323 -0
  32. package/extension/.vscodeignore +15 -0
  33. package/extension/CHANGELOG.md +1906 -0
  34. package/extension/LICENSE +21 -0
  35. package/extension/README.md +137 -0
  36. package/extension/docs/features.md +424 -0
  37. package/extension/docs/protocol.md +197 -0
  38. package/extension/esbuild.mjs +25 -0
  39. package/extension/icon.png +0 -0
  40. package/extension/native/.metadata.json +10 -0
  41. package/extension/native/node-pty/LICENSE +69 -0
  42. package/extension/native/node-pty/README.md +165 -0
  43. package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
  44. package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
  45. package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
  46. package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
  47. package/extension/native/node-pty/lib/index.js +52 -0
  48. package/extension/native/node-pty/lib/index.js.map +1 -0
  49. package/extension/native/node-pty/lib/interfaces.js +7 -0
  50. package/extension/native/node-pty/lib/interfaces.js.map +1 -0
  51. package/extension/native/node-pty/lib/shared/conout.js +11 -0
  52. package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
  53. package/extension/native/node-pty/lib/terminal.js +190 -0
  54. package/extension/native/node-pty/lib/terminal.js.map +1 -0
  55. package/extension/native/node-pty/lib/types.js +7 -0
  56. package/extension/native/node-pty/lib/types.js.map +1 -0
  57. package/extension/native/node-pty/lib/unixTerminal.js +346 -0
  58. package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
  59. package/extension/native/node-pty/lib/utils.js +39 -0
  60. package/extension/native/node-pty/lib/utils.js.map +1 -0
  61. package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
  62. package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
  63. package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
  64. package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
  65. package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
  66. package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
  67. package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  68. package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
  69. package/extension/native/node-pty/package.json +64 -0
  70. package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  71. package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  72. package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  73. package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  74. package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  75. package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  76. package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  77. package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  78. package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
  79. package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
  80. package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
  81. package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  82. package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  83. package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  84. package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  85. package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
  86. package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
  87. package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
  88. package/extension/package-lock.json +605 -0
  89. package/extension/package.json +343 -0
  90. package/extension/scripts/bundle-native.mjs +104 -0
  91. package/extension/scripts/deploy-dev.mjs +60 -0
  92. package/extension/src/ansi-strip.ts +52 -0
  93. package/extension/src/backends/vscode/claws-pty.ts +483 -0
  94. package/extension/src/backends/vscode/status-bar.ts +99 -0
  95. package/extension/src/backends/vscode/vscode-backend.ts +282 -0
  96. package/extension/src/capture-store.ts +125 -0
  97. package/extension/src/event-log.ts +629 -0
  98. package/extension/src/event-schemas.ts +478 -0
  99. package/extension/src/extension.js +492 -0
  100. package/extension/src/extension.ts +873 -0
  101. package/extension/src/lifecycle-engine.ts +60 -0
  102. package/extension/src/lifecycle-rules.ts +171 -0
  103. package/extension/src/lifecycle-store.ts +506 -0
  104. package/extension/src/peer-registry.ts +176 -0
  105. package/extension/src/pipeline-registry.ts +82 -0
  106. package/extension/src/platform.ts +64 -0
  107. package/extension/src/protocol.ts +532 -0
  108. package/extension/src/server-config.ts +98 -0
  109. package/extension/src/server.ts +2210 -0
  110. package/extension/src/task-registry.ts +51 -0
  111. package/extension/src/terminal-backend.ts +211 -0
  112. package/extension/src/terminal-manager.ts +395 -0
  113. package/extension/src/topic-registry.ts +70 -0
  114. package/extension/src/topic-utils.ts +46 -0
  115. package/extension/src/transport.ts +45 -0
  116. package/extension/src/uninstall-cleanup.ts +232 -0
  117. package/extension/src/wave-registry.ts +314 -0
  118. package/extension/src/websocket-transport.ts +153 -0
  119. package/extension/tsconfig.json +23 -0
  120. package/lib/capabilities.js +145 -0
  121. package/lib/dry-run.js +43 -0
  122. package/lib/install.js +1018 -0
  123. package/lib/mcp-setup.js +92 -0
  124. package/lib/platform.js +240 -0
  125. package/lib/preflight.js +152 -0
  126. package/lib/shell-hook.js +343 -0
  127. package/lib/uninstall.js +162 -0
  128. package/lib/verify.js +166 -0
  129. package/mcp_server.js +3529 -0
  130. package/package.json +48 -0
  131. package/rules/claws-default-behavior.md +72 -0
  132. package/scripts/_helpers/atomic-file.mjs +137 -0
  133. package/scripts/_helpers/fix-repair.js +64 -0
  134. package/scripts/_helpers/json-safe.mjs +218 -0
  135. package/scripts/bump-version.sh +84 -0
  136. package/scripts/codegen/gen-docs.mjs +61 -0
  137. package/scripts/codegen/gen-json-schema.mjs +62 -0
  138. package/scripts/codegen/gen-mcp-tools.mjs +358 -0
  139. package/scripts/codegen/gen-types.mjs +172 -0
  140. package/scripts/codegen/index.mjs +42 -0
  141. package/scripts/dev-hooks/check-extension-dirs.js +77 -0
  142. package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
  143. package/scripts/dev-hooks/check-stale-main.js +55 -0
  144. package/scripts/dev-hooks/check-tag-pushed.js +51 -0
  145. package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
  146. package/scripts/dev-vsix-install.sh +60 -0
  147. package/scripts/fix.sh +702 -0
  148. package/scripts/gen-client-types.mjs +81 -0
  149. package/scripts/git-hooks/pre-commit +31 -0
  150. package/scripts/hooks/lifecycle-state.js +61 -0
  151. package/scripts/hooks/package.json +4 -0
  152. package/scripts/hooks/post-tool-use-claws.js +292 -0
  153. package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
  154. package/scripts/hooks/pre-tool-use-claws.js +206 -0
  155. package/scripts/hooks/session-start-claws.js +97 -0
  156. package/scripts/hooks/stop-claws.js +88 -0
  157. package/scripts/inject-claude-md.js +205 -0
  158. package/scripts/inject-dev-hooks.js +96 -0
  159. package/scripts/inject-global-claude-md.js +140 -0
  160. package/scripts/inject-settings-hooks.js +370 -0
  161. package/scripts/install.ps1 +146 -0
  162. package/scripts/install.sh +1729 -0
  163. package/scripts/monitor-arm-watch.js +155 -0
  164. package/scripts/rebuild-node-pty.sh +245 -0
  165. package/scripts/report.sh +232 -0
  166. package/scripts/shell-hook.fish +164 -0
  167. package/scripts/shell-hook.ps1 +33 -0
  168. package/scripts/shell-hook.sh +232 -0
  169. package/scripts/stream-events.js +399 -0
  170. package/scripts/terminal-wrapper.sh +36 -0
  171. package/scripts/test-enforcement.sh +132 -0
  172. package/scripts/test-install.sh +174 -0
  173. package/scripts/test-installer-parity.sh +135 -0
  174. package/scripts/test-template-enforcement.sh +76 -0
  175. package/scripts/uninstall.sh +143 -0
  176. package/scripts/update.sh +337 -0
  177. package/scripts/verify-release.sh +323 -0
  178. package/scripts/verify-wrapped.sh +194 -0
  179. package/templates/CLAUDE.global.md +135 -0
  180. 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
+ }