@elvatis_com/openclaw-self-healing-elvatis 0.2.4 → 0.2.7

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.
@@ -52,11 +52,12 @@
52
52
  "created": "2026-02-27T23:00:00Z",
53
53
  "priority": "high",
54
54
  "title": "Implement structured plugin health monitoring and auto-disable",
55
- "status": "blocked",
55
+ "status": "done",
56
56
  "depends_on": [],
57
57
  "blocked_reason": "Waiting for openclaw plugins list --json API",
58
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
59
- "github_issue": 3
58
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
59
+ "github_issue": 3,
60
+ "completed": "2026-03-07T06:58:52.606Z"
60
61
  },
61
62
  "T-006": {
62
63
  "title": "Support configuration hot-reload without gateway restart",
@@ -66,7 +67,7 @@
66
67
  "created": "2026-02-28T13:25:43.201Z",
67
68
  "notes": "## Summary\n\nPlugin config (`modelOrder`, `cooldownMinutes`, `autoFix.*`) is read once at startup via `api.pluginConfig`. Changing any config value requires a full gateway restart.\n\n## Scope\n\n- Watch for config changes (file watch or periodic re-read)\n- Re-read plugin config on change and update internal variables\n- Handle edge cases: invalid new config (keep old), partial updates\n- Alternatively, support a reload command: `openclaw self-heal reload`\n\n## Acceptance criteria\n\n- Config changes take",
68
69
  "github_issue": 8,
69
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
70
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
70
71
  "completed": "2026-02-28T13:42:34.823Z"
71
72
  },
72
73
  "T-007": {
@@ -77,7 +78,7 @@
77
78
  "created": "2026-02-28T13:25:43.204Z",
78
79
  "notes": "## Summary\n\nModels in cooldown are currently recovered passively: `pickFallback()` checks `nextAvailableAt` only when a new failure occurs. If a model recovers early, the plugin still uses the fallback until the full cooldown expires.\n\n## Scope\n\n- Add a periodic probe (e.g., every 5 minutes) that tests cooldown models\n- Use a lightweight API call (e.g., model info endpoint or small completion)\n- If the model responds successfully, remove it from cooldown early\n- Configurable probe interval and e",
79
80
  "github_issue": 7,
80
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
81
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
81
82
  "completed": "2026-02-28T14:42:14.373Z"
82
83
  },
83
84
  "T-008": {
@@ -88,7 +89,7 @@
88
89
  "created": "2026-02-28T13:25:43.204Z",
89
90
  "notes": "## Summary\n\nThere is no way to test what the plugin would do without it actually taking healing actions (restarting gateways, disabling crons, patching sessions). A dry-run mode is needed for validation and debugging.\n\n## Scope\n\n- Add a `dryRun: boolean` config option (default: false)\n- When enabled: log all actions that _would_ be taken, but do not execute them\n- State tracking still updates (to test state transitions) but side-effects are skipped\n- Useful for: initial setup validation, debuggi",
90
91
  "github_issue": 6,
91
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
92
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
92
93
  "completed": "2026-02-28T15:14:21.885Z"
93
94
  },
94
95
  "T-009": {
@@ -99,7 +100,7 @@
99
100
  "created": "2026-02-28T13:25:43.204Z",
100
101
  "notes": "## Summary\n\nThe plugin uses `api.logger` for logging but emits no structured events. Monitoring and alerting systems cannot track heal actions, cooldown entries, or failure rates.\n\n## Scope\n\n- Emit structured events via `api.emit()` or equivalent for:\n - `self-heal:model-cooldown` - model put into cooldown (with model ID, reason, duration)\n - `self-heal:session-patched` - session model pin overridden (with session key, old/new model)\n - `self-heal:whatsapp-restart` - WhatsApp gateway restarte",
101
102
  "github_issue": 5,
102
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
103
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
103
104
  "completed": "2026-02-28T15:23:54.846Z"
104
105
  },
105
106
  "T-010": {
@@ -110,7 +111,7 @@
110
111
  "created": "2026-02-28T13:25:43.204Z",
111
112
  "notes": "## Summary\n\nExternal tools (dashboards, other plugins, CLI) cannot query the self-heal plugin state. There is no way to know which models are in cooldown, how many heals have occurred, or the current WhatsApp connection status without reading the raw state file.\n\n## Scope\n\n- Register a plugin command or API endpoint (depends on openclaw plugin API):\n - `openclaw self-heal status` or similar\n - Returns JSON with: cooldown models, WhatsApp status, cron heal history, last heal actions\n- Alternati",
112
113
  "github_issue": 4,
113
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
114
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
114
115
  "completed": "2026-02-28T16:15:24.131Z"
115
116
  },
116
117
  "T-011": {
@@ -121,7 +122,7 @@
121
122
  "created": "2026-03-02T00:00:00Z",
122
123
  "notes": "## Summary\n\nThe current test suite has 122 unit tests covering helpers and pure functions (parseConfig, pickFallback, buildStatusSnapshot, etc.) but the monitor service tick() function runs all healing logic and is not covered by integration tests.\n\n## Scope\n\n- Add integration tests for the full monitor tick cycle using a mocked api object\n- Cover: WhatsApp disconnect streak -> restart path\n- Cover: cron failure accumulation -> disable + issue create path\n- Cover: active model recovery probe -> cooldown removal path\n- Cover: config hot-reload during tick\n- Cover: dry-run flag suppresses all side-effects\n- Use Jest timer mocks for setInterval control\n\n## Acceptance criteria\n\n- At least 20 new integration-level tests added\n- All healing paths (WhatsApp, cron, probe) exercised in tests\n- Tests pass with npm test",
123
124
  "github_issue": 9,
124
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
125
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
125
126
  "completed": "2026-03-02T02:17:03.808Z"
126
127
  },
127
128
  "T-012": {
@@ -132,7 +133,7 @@
132
133
  "created": "2026-03-02T00:00:00Z",
133
134
  "notes": "## Summary\n\nCurrently parseConfig() silently falls back to defaults for any invalid value. The plugin starts even if modelOrder is empty, paths are not writable, or cooldownMinutes is set to an absurd value. This makes misconfiguration hard to diagnose.\n\n## Scope\n\n- Add a validateConfig(config: PluginConfig): { valid: boolean; errors: string[] } function\n- Validate: modelOrder has at least one entry\n- Validate: cooldownMinutes is between 1 and 10080 (1 week)\n- Validate: probeIntervalSec >= 60\n- Validate: whatsappMinRestartIntervalSec >= 60\n- Validate: state file directory is writable (best-effort)\n- Log each validation error clearly with api.logger?.error\n- Return early from register() if validation fails (fail-fast)\n\n## Acceptance criteria\n\n- validateConfig function is exported and unit-tested\n- Plugin refuses to start on invalid config and logs all reasons\n- README config section updated with valid ranges",
134
135
  "github_issue": 10,
135
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
136
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
136
137
  "completed": "2026-03-02T02:50:50.546Z"
137
138
  },
138
139
  "T-013": {
@@ -143,7 +144,7 @@
143
144
  "created": "2026-03-02T00:00:00Z",
144
145
  "notes": "## Summary\n\nThe plugin already builds a StatusSnapshot object and emits it via api.emit('self-heal:status', snapshot) on each tick. However, external tools (scripts, dashboards, other plugins) cannot read this snapshot without subscribing to the event bus. A file-based status report would allow polling from any tool.\n\n## Scope\n\n- After each tick, write buildStatusSnapshot(state, config) to a JSON file\n- Default path: ~/.openclaw/workspace/memory/self-heal-status.json (configurable via statusFile config key)\n- Write atomically (write to .tmp then rename)\n- Add statusFile to PluginConfig type and parseConfig\n- Add a writeStatusFile(path, snapshot) helper function (exported for tests)\n\n## Acceptance criteria\n\n- Status file is written on every tick\n- File contains valid JSON matching StatusSnapshot type\n- Atomic write prevents partial reads\n- Tests cover writeStatusFile helper\n- README documents the statusFile config key and file format",
145
146
  "github_issue": 11,
146
- "github_repo": "elvatis/openclaw-self-healing-homeofe",
147
+ "github_repo": "elvatis/openclaw-self-healing-elvatis",
147
148
  "completed": "2026-03-02T03:16:30.819Z"
148
149
  },
149
150
  "T-014": {
@@ -154,12 +155,12 @@
154
155
  "created": "2026-03-02T00:00:00Z",
155
156
  "notes": "## Summary\n\nHeal events are currently only visible in logs and via api.emit(). There is no persistent record of past heal actions for analysis or alerting. The aahp-runner project writes structured metrics to ~/.aahp/metrics.jsonl; the self-heal plugin should do the same.\n\n## Scope\n\n- Add an appendMetric(line: object, metricsFile: string) helper that appends a JSON line\n- Emit one JSONL line per heal event:\n - { ts, plugin: \"self-heal\", event: \"model-cooldown\", model, reason, cooldownSec }\n - { ts, plugin: \"self-heal\", event: \"session-patched\", sessionKey, oldModel, newModel }\n - { ts, plugin: \"self-heal\", event: \"whatsapp-restart\", disconnectStreak }\n - { ts, plugin: \"self-heal\", event: \"cron-disabled\", cronId, cronName, consecutiveFailures }\n - { ts, plugin: \"self-heal\", event: \"model-recovered\", model, isPreferred }\n- Default metrics file: ~/.aahp/metrics.jsonl (configurable via metricsFile config key)\n- Skip metrics write in dry-run mode (or mark with dryRun: true)\n- Create parent directory if missing\n\n## Acceptance criteria\n\n- appendMetric helper is exported and unit-tested\n- All 5 heal event types write a metrics line\n- Dry-run events are either skipped or marked\n- README documents the metrics format and metricsFile config key",
156
157
  "github_issue": 12,
157
- "github_repo": "elvatis/openclaw-self-healing-homeofe"
158
+ "github_repo": "elvatis/openclaw-self-healing-elvatis"
158
159
  }
159
160
  },
160
161
  "quick_context": "T-013 done: writeStatusFile() helper with atomic write (tmp+rename) added. statusFile config key with default ~/.openclaw/workspace/memory/self-heal-status.json. Written on every monitor tick. 9 new tests, total 181 passing. CI green. T-005 still blocked on openclaw plugins list --json API. Remaining v0.3 task: T-014 (metrics export).",
161
162
  "aahp_version": "3.0",
162
- "project": "openclaw-self-healing-homeofe",
163
+ "project": "openclaw-self-healing-elvatis",
163
164
  "token_budget": {
164
165
  "full_read": 1000,
165
166
  "manifest_only": 100,
package/README.md CHANGED
@@ -57,7 +57,7 @@ openclaw gateway restart
57
57
  "patchSessionPins": true,
58
58
  "disableFailingPlugins": false,
59
59
  "disableFailingCrons": false,
60
- "issueRepo": "elvatis/openclaw-self-healing-homeofe"
60
+ "issueRepo": "elvatis/openclaw-self-healing-elvatis"
61
61
  }
62
62
  }
63
63
  }
@@ -66,7 +66,7 @@ openclaw gateway restart
66
66
  }
67
67
  ```
68
68
 
69
- `autoFix.issueRepo` must use `owner/repo` format. Invalid values are ignored and the plugin falls back to `GITHUB_REPOSITORY` (if valid) or `elvatis/openclaw-self-healing-homeofe`.
69
+ `autoFix.issueRepo` must use `owner/repo` format. Invalid values are ignored and the plugin falls back to `GITHUB_REPOSITORY` (if valid) or `elvatis/openclaw-self-healing-elvatis`.
70
70
 
71
71
  ### Config validation
72
72
 
@@ -94,15 +94,29 @@ The file is written atomically (write to `.tmp` then rename) to prevent partial
94
94
  "health": "healthy | degraded | healing",
95
95
  "activeModel": "anthropic/claude-opus-4-6",
96
96
  "models": [
97
- { "id": "...", "status": "available | cooldown", "cooldownRemainingSec": 1234 }
97
+ {
98
+ "id": "anthropic/claude-opus-4-6",
99
+ "status": "available | cooldown",
100
+ "cooldownReason": "rate limit (only when in cooldown)",
101
+ "cooldownRemainingSec": 1234,
102
+ "nextAvailableAt": 1700001234,
103
+ "lastProbeAt": 1700000900
104
+ }
98
105
  ],
99
- "whatsapp": { "status": "connected | disconnected | unknown", "disconnectStreak": 0 },
106
+ "whatsapp": {
107
+ "status": "connected | disconnected | unknown",
108
+ "disconnectStreak": 0,
109
+ "lastRestartAt": null,
110
+ "lastSeenConnectedAt": 1700000000
111
+ },
100
112
  "cron": { "trackedJobs": 2, "failingJobs": [] },
101
113
  "config": { "dryRun": false, "probeEnabled": true, "cooldownMinutes": 300, "modelOrder": ["..."] },
102
114
  "generatedAt": 1700000000
103
115
  }
104
116
  ```
105
117
 
118
+ Fields `cooldownReason`, `cooldownRemainingSec`, `nextAvailableAt`, and `lastProbeAt` on model entries are only present when the model is in cooldown.
119
+
106
120
  ## Notes
107
121
 
108
122
  Infrastructure changes remain ask-first.
package/index.ts CHANGED
@@ -158,7 +158,7 @@ export function isValidIssueRepoSlug(value: string): boolean {
158
158
  }
159
159
 
160
160
  export function resolveIssueRepo(configValue: unknown, envValue: unknown): string {
161
- const defaultRepo = "elvatis/openclaw-self-healing-homeofe";
161
+ const defaultRepo = "elvatis/openclaw-self-healing-elvatis";
162
162
  const candidates = [configValue, envValue, defaultRepo];
163
163
  for (const candidate of candidates) {
164
164
  if (typeof candidate !== "string") continue;
@@ -350,7 +350,14 @@ export function patchSessionModel(sessionsFile: string, sessionKey: string, mode
350
350
  if (!data[sessionKey]) return false;
351
351
  const prev = data[sessionKey].model;
352
352
  data[sessionKey].model = model;
353
- fs.writeFileSync(sessionsFile, JSON.stringify(data, null, 0));
353
+ // atomic write: write to tmp file then rename
354
+ const tmpFile = `${sessionsFile}.tmp.${Date.now()}`;
355
+ try {
356
+ fs.writeFileSync(tmpFile, JSON.stringify(data, null, 0), { encoding: "utf-8", mode: 0o600 });
357
+ fs.renameSync(tmpFile, sessionsFile);
358
+ } finally {
359
+ try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ }
360
+ }
354
361
  logger?.warn?.(`[self-heal] patched session model: ${sessionKey} ${prev} -> ${model}`);
355
362
  return true;
356
363
  } catch (e: any) {
@@ -361,15 +368,18 @@ export function patchSessionModel(sessionsFile: string, sessionKey: string, mode
361
368
 
362
369
  async function runCmd(api: any, cmd: string, timeoutMs = 15000): Promise<{ ok: boolean; stdout: string; stderr: string; code?: number }> {
363
370
  try {
364
- const res = await api.runtime.system.runCommandWithTimeout({
365
- command: ["bash", "-lc", cmd],
366
- timeoutMs,
367
- });
371
+ // runCommandWithTimeout(argv: string[], options) — two separate args, not one object.
372
+ // SpawnResult uses `code`; accept `exitCode` too for mock compatibility.
373
+ const res = await api.runtime.system.runCommandWithTimeout(
374
+ ["bash", "-lc", cmd],
375
+ { timeoutMs },
376
+ );
377
+ const exitCode = res.code ?? (res as any).exitCode ?? null;
368
378
  return {
369
- ok: res.exitCode === 0,
379
+ ok: exitCode === 0,
370
380
  stdout: String(res.stdout ?? ""),
371
381
  stderr: String(res.stderr ?? ""),
372
- code: res.exitCode,
382
+ code: exitCode ?? undefined,
373
383
  };
374
384
  } catch (e: any) {
375
385
  return { ok: false, stdout: "", stderr: e?.message ?? String(e) };
@@ -748,17 +758,70 @@ export default function register(api: any) {
748
758
 
749
759
  // --- Plugin error rollback (disable plugin) ---
750
760
  if (config.disableFailingPlugins) {
751
- const res = await runCmd(api, "openclaw plugins list", 15000);
761
+ const res = await runCmd(api, "openclaw plugins list --json", 15000);
752
762
  if (res.ok) {
753
- // Heuristic: look for lines containing 'error' or 'crash'
754
- const lines = res.stdout.split("\n");
755
- for (const ln of lines) {
756
- if (!ln.toLowerCase().includes("error")) continue;
757
- // No robust parsing available in plain output. Use a conservative approach:
758
- // if we see our own plugin listed with error, do not disable others.
763
+ type PluginEntry = { id: string; name?: string; enabled?: boolean; status?: string; version?: string; error?: string };
764
+ const parsed = safeJsonParse<{ plugins: PluginEntry[] }>(res.stdout);
765
+ const plugins: PluginEntry[] = parsed?.plugins ?? [];
766
+ const selfId = "openclaw-self-healing-elvatis";
767
+
768
+ for (const plugin of plugins) {
769
+ const id = plugin.id;
770
+ if (!id || id === selfId) continue; // never disable ourselves
771
+
772
+ const isFailing = plugin.status === "error" || plugin.status === "crash";
773
+ if (!isFailing) continue;
774
+
775
+ const lastDisableAt = state.plugins!.lastDisableAt![id] ?? 0;
776
+ if (nowSec() - lastDisableAt < config.pluginDisableCooldownSec) {
777
+ api.logger?.info?.(`[self-heal] plugin ${id} still in disable cooldown, skipping`);
778
+ continue;
779
+ }
780
+
781
+ const name = plugin.name ?? id;
782
+ const failReason = plugin.error ? `status=${plugin.status} error=${plugin.error}` : `status=${plugin.status}`;
783
+
784
+ if (config.dryRun) {
785
+ api.logger?.info?.(`[self-heal] [dry-run] would disable plugin ${name} (${id}), reason=${failReason}`);
786
+ state.plugins!.lastDisableAt![id] = nowSec();
787
+ } else {
788
+ const v = isConfigValid();
789
+ if (!v.ok) {
790
+ api.logger?.error?.(`[self-heal] NOT disabling plugin: openclaw.json invalid: ${v.error}`);
791
+ continue;
792
+ }
793
+ api.logger?.warn?.(`[self-heal] Disabling failing plugin ${name} (${id}), reason=${failReason}`);
794
+ backupConfig("pre-plugin-disable");
795
+ await runCmd(api, `openclaw plugins disable ${shellQuote(id)}`, 15000);
796
+ await cleanupPendingBackups("post-plugin-disable");
797
+ state.plugins!.lastDisableAt![id] = nowSec();
798
+
799
+ const body = [
800
+ `Plugin was detected as failing and disabled by openclaw-self-healing.`,
801
+ ``,
802
+ `Plugin ID: ${id}`,
803
+ `Plugin Name: ${name}`,
804
+ `Version: ${plugin.version ?? "unknown"}`,
805
+ `Status: ${plugin.status}`,
806
+ ...(plugin.error ? [`Error: ${plugin.error}`] : []),
807
+ ].join("\n");
808
+ const issueCommand = buildGhIssueCreateCommand({
809
+ repo: config.issueRepo,
810
+ title: `Plugin disabled: ${name}`,
811
+ body,
812
+ labels: ["security"],
813
+ });
814
+ await runCmd(api, issueCommand, 20000);
815
+ }
816
+
817
+ api.emit?.("self-heal:plugin-disabled", {
818
+ pluginId: id,
819
+ pluginName: name,
820
+ reason: failReason,
821
+ dryRun: config.dryRun,
822
+ });
759
823
  }
760
824
  }
761
- // TODO: when openclaw provides plugins list --json, parse and disable any status=error.
762
825
  }
763
826
 
764
827
  // --- Active model recovery probing ---
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-self-healing-elvatis",
3
3
  "name": "OpenClaw Self Healing",
4
- "version": "0.2.5",
4
+ "version": "0.2.7",
5
5
  "description": "Self-healing health checks + guardrails + auto-fix for reversible failures (rate limits, auth errors, stuck session pins).",
6
6
  "configSchema": {
7
7
  "$schema": "http://json-schema.org/draft-07/schema#",
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "issueRepo": {
97
97
  "type": "string",
98
- "default": "elvatis/openclaw-self-healing-homeofe",
98
+ "default": "elvatis/openclaw-self-healing-elvatis",
99
99
  "description": "GitHub repository (owner/repo) where auto-created cron failure issues should be opened."
100
100
  },
101
101
  "pluginDisableCooldownSec": {
@@ -107,4 +107,4 @@
107
107
  }
108
108
  }
109
109
  }
110
- }
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-self-healing-elvatis",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "openclaw": {
6
6
  "extensions": [
@@ -9,7 +9,7 @@
9
9
 
10
10
  set -euo pipefail
11
11
 
12
- REPO="elvatis/openclaw-self-healing-homeofe"
12
+ REPO="elvatis/openclaw-self-healing-elvatis"
13
13
  DRY_RUN=false
14
14
  if [[ "${1:-}" == "--dry-run" ]]; then
15
15
  DRY_RUN=true
@@ -1087,7 +1087,7 @@ describe("register", () => {
1087
1087
  // not for a probe (since the model's cooldown already expired)
1088
1088
  const probeCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1089
1089
  (c: any[]) => {
1090
- const cmd = c[0]?.command?.join(" ") ?? "";
1090
+ const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
1091
1091
  return cmd.includes("model probe");
1092
1092
  }
1093
1093
  );
@@ -1214,7 +1214,7 @@ describe("register", () => {
1214
1214
  // Should NOT have called the actual probe command
1215
1215
  const probeCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1216
1216
  (c: any[]) => {
1217
- const cmd = c[0]?.command?.join(" ") ?? "";
1217
+ const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
1218
1218
  return cmd.includes("model probe");
1219
1219
  }
1220
1220
  );
@@ -1244,7 +1244,7 @@ describe("register", () => {
1244
1244
  // model-a probe succeeds, model-b probe fails
1245
1245
  let probeCount = 0;
1246
1246
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
1247
- const cmd = opts?.command?.join(" ") ?? "";
1247
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
1248
1248
  if (cmd.includes("model probe")) {
1249
1249
  probeCount++;
1250
1250
  if (cmd.includes("model-a")) {
@@ -1420,7 +1420,7 @@ describe("register", () => {
1420
1420
  // Should NOT have called gateway restart
1421
1421
  const restartCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1422
1422
  (c: any[]) => {
1423
- const cmd = c[0]?.command?.join(" ") ?? "";
1423
+ const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
1424
1424
  return cmd.includes("gateway restart");
1425
1425
  }
1426
1426
  );
@@ -1472,7 +1472,7 @@ describe("register", () => {
1472
1472
  // Should NOT have called cron edit disable
1473
1473
  const cronCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1474
1474
  (c: any[]) => {
1475
- const cmd = c[0]?.command?.join(" ") ?? "";
1475
+ const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
1476
1476
  return cmd.includes("cron edit");
1477
1477
  }
1478
1478
  );
@@ -1481,7 +1481,7 @@ describe("register", () => {
1481
1481
  // Should NOT have called gh issue create
1482
1482
  const issueCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1483
1483
  (c: any[]) => {
1484
- const cmd = c[0]?.command?.join(" ") ?? "";
1484
+ const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
1485
1485
  return cmd.includes("gh issue create");
1486
1486
  }
1487
1487
  );
@@ -1963,7 +1963,7 @@ describe("parseConfig", () => {
1963
1963
  expect(c.whatsappMinRestartIntervalSec).toBe(300);
1964
1964
  expect(c.cronFailThreshold).toBe(3);
1965
1965
  expect(c.issueCooldownSec).toBe(6 * 3600);
1966
- expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-homeofe");
1966
+ expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-elvatis");
1967
1967
  expect(c.pluginDisableCooldownSec).toBe(3600);
1968
1968
  expect(c.probeEnabled).toBe(true);
1969
1969
  expect(c.probeIntervalSec).toBe(300);
@@ -2006,7 +2006,7 @@ describe("parseConfig", () => {
2006
2006
 
2007
2007
  it("falls back to default when configured issueRepo is invalid", () => {
2008
2008
  const c = parseConfig({ autoFix: { issueRepo: "not-a-slug" } });
2009
- expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-homeofe");
2009
+ expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-elvatis");
2010
2010
  });
2011
2011
 
2012
2012
  it("does not share modelOrder array reference with input", () => {
@@ -2047,7 +2047,7 @@ describe("parseConfig", () => {
2047
2047
 
2048
2048
  describe("GitHub issue helpers", () => {
2049
2049
  it("validates owner/repo slug", () => {
2050
- expect(isValidIssueRepoSlug("elvatis/openclaw-self-healing-homeofe")).toBe(true);
2050
+ expect(isValidIssueRepoSlug("elvatis/openclaw-self-healing-elvatis")).toBe(true);
2051
2051
  expect(isValidIssueRepoSlug("owner/repo_1")).toBe(true);
2052
2052
  expect(isValidIssueRepoSlug("bad")).toBe(false);
2053
2053
  expect(isValidIssueRepoSlug("owner/repo/extra")).toBe(false);
@@ -2056,7 +2056,7 @@ describe("GitHub issue helpers", () => {
2056
2056
  it("resolves issue repo from config then env then default", () => {
2057
2057
  expect(resolveIssueRepo("owner/config-repo", "owner/env-repo")).toBe("owner/config-repo");
2058
2058
  expect(resolveIssueRepo("bad", "owner/env-repo")).toBe("owner/env-repo");
2059
- expect(resolveIssueRepo(undefined, "bad")).toBe("elvatis/openclaw-self-healing-homeofe");
2059
+ expect(resolveIssueRepo(undefined, "bad")).toBe("elvatis/openclaw-self-healing-elvatis");
2060
2060
  });
2061
2061
 
2062
2062
  it("builds shell-safe gh issue command with labels", () => {
@@ -2354,7 +2354,7 @@ describe("validateConfig", () => {
2354
2354
  whatsappMinRestartIntervalSec: 300,
2355
2355
  cronFailThreshold: 3,
2356
2356
  issueCooldownSec: 21600,
2357
- issueRepo: "elvatis/openclaw-self-healing-homeofe",
2357
+ issueRepo: "elvatis/openclaw-self-healing-elvatis",
2358
2358
  pluginDisableCooldownSec: 3600,
2359
2359
  probeEnabled: true,
2360
2360
  probeIntervalSec: 300,
@@ -79,7 +79,13 @@ async function runOneTick(api: ReturnType<typeof mockApi>) {
79
79
 
80
80
  /** Build a command-matching predicate for runCommandWithTimeout call args. */
81
81
  function cmdContains(call: any[], fragment: string): boolean {
82
- const cmd = call[0]?.command?.join(" ") ?? "";
82
+ // Handles both call signatures:
83
+ // old (broken): runCommandWithTimeout({ command: string[], timeoutMs })
84
+ // new (correct): runCommandWithTimeout(string[], { timeoutMs })
85
+ const argv: string[] | undefined = Array.isArray(call[0])
86
+ ? call[0]
87
+ : call[0]?.command;
88
+ const cmd = (argv ?? []).join(" ");
83
89
  return cmd.includes(fragment);
84
90
  }
85
91
 
@@ -195,7 +201,7 @@ describe("monitor service - integration tick flows", () => {
195
201
  },
196
202
  });
197
203
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
198
- const cmd = opts?.command?.join(" ") ?? "";
204
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
199
205
  if (cmd.includes("channels status")) {
200
206
  return {
201
207
  exitCode: 0,
@@ -319,7 +325,7 @@ describe("monitor service - integration tick flows", () => {
319
325
  },
320
326
  });
321
327
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
322
- const cmd = opts?.command?.join(" ") ?? "";
328
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
323
329
  commandLog.push(cmd);
324
330
  if (cmd.includes("channels status")) {
325
331
  return {
@@ -459,7 +465,7 @@ describe("monitor service - integration tick flows", () => {
459
465
  },
460
466
  });
461
467
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
462
- const cmd = opts?.command?.join(" ") ?? "";
468
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
463
469
  if (cmd.includes("cron list")) {
464
470
  return {
465
471
  exitCode: 0,
@@ -527,7 +533,7 @@ describe("monitor service - integration tick flows", () => {
527
533
  },
528
534
  });
529
535
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
530
- const cmd = opts?.command?.join(" ") ?? "";
536
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
531
537
  if (cmd.includes("cron list")) {
532
538
  return {
533
539
  exitCode: 0,
@@ -660,7 +666,7 @@ describe("monitor service - integration tick flows", () => {
660
666
  },
661
667
  });
662
668
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
663
- const cmd = opts?.command?.join(" ") ?? "";
669
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
664
670
  if (cmd.includes("model probe")) {
665
671
  return { exitCode: 0, stdout: "ok", stderr: "" };
666
672
  }
@@ -700,7 +706,7 @@ describe("monitor service - integration tick flows", () => {
700
706
  },
701
707
  });
702
708
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
703
- const cmd = opts?.command?.join(" ") ?? "";
709
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
704
710
  if (cmd.includes("model probe")) {
705
711
  return { exitCode: 1, stdout: "", stderr: "still limited" };
706
712
  }
@@ -739,7 +745,7 @@ describe("monitor service - integration tick flows", () => {
739
745
  },
740
746
  });
741
747
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
742
- const cmd = opts?.command?.join(" ") ?? "";
748
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
743
749
  if (cmd.includes("model probe")) {
744
750
  return { exitCode: 0, stdout: "ok", stderr: "" };
745
751
  }
@@ -1181,7 +1187,7 @@ describe("monitor service - integration tick flows", () => {
1181
1187
  },
1182
1188
  });
1183
1189
  api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
1184
- const cmd = opts?.command?.join(" ") ?? "";
1190
+ const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
1185
1191
  if (cmd.includes("channels status")) {
1186
1192
  return {
1187
1193
  exitCode: 0,
@@ -1267,7 +1273,7 @@ describe("monitor service - integration tick flows", () => {
1267
1273
  // that no healing-specific commands were issued
1268
1274
  const healingCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1269
1275
  (c: any[]) => {
1270
- const cmd = c[0]?.command?.join(" ") ?? "";
1276
+ const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
1271
1277
  return cmd.includes("channels status") || cmd.includes("cron list") || cmd.includes("model probe");
1272
1278
  }
1273
1279
  );