Skill: netfeel-star/inspector (version 1.0.3)

--- name: inspector description: OpenClaw inspector for registering tracked sessions, inspecting stuck or inactive sessions, checking the current session UUID, listing status, and preparing platform-specific watcher services. Use when the user wants session recovery monitoring, inactivity inspection, registration/unregistration, or diagnosis of why a session was or was not inspected. Only perform install/start/enable/restart actions when the user explicitly asks. --- # Inspector ## Manage registrations and status Prefer the Node.js entrypoint for cross-platform use: - `node scripts/inspector.js register ...` - `node scripts/inspector.js unregister ...` - `node scripts/inspector.js list` - `node scripts/inspector.js status ...` - `node scripts/inspector.js install` - `node scripts/inspector.js doctor` On Linux/macOS, `scripts/inspector.js` is only a compatibility wrapper around the Node.js script. ## Code vs runtime data Keep executable code in the skill itself: - `scripts/inspector.js` - `scripts/watch-registered-sessions.js` - `scripts/common.js` Keep mutable runtime data outside the skill directory under the inspector runtime home: - `~/.openclaw/inspector/registry.json` - `~/.openclaw/inspector/config.env` - `~/.openclaw/inspector/state/` - `~/.openclaw/inspector/logs/` The agent should execute scripts from the skill directory, not search runtime directories for copies. Supported subcommands: - `register` - `unregister` - `list` - `status` - `install` - `doctor` ## Get the current session UUID Before registering, obtain the actual OpenClaw session UUID in 3 steps: **1. Get the current session key** The session key is available in every message metadata (no API call needed). Example format: `agent:assistant2:telegram:direct:8298444890` — this is just an illustration; use the actual key from your runtime context. **2. Call `sessions_list`** ```javascript sessions_list({ limit: 10, messageLimit: 1 }) ``` **3. Match the key to find the UUID** In the returned results, find the entry whose `key` field equals your session key from step 1. That entry's `sessionId` is the actual OpenClaw session UUID — pass this value to `--session-id`. Do not use `current`, `this`, or `latest` as session identifiers. ## Register a session ```bash node scripts/inspector.js register \ --session-id <id> \ --session-key <key> \ --reply-channel <channel> \ --reply-account <accountId> \ --to <target> \ [--agent <name>] \ [--workspace <path>] \ [--profile <name>] \ [--inactive <sec>] \ [--cooldown <sec>] \ [--running-cooldown <sec>] \ [--blocked-cooldown <sec>] \ [--notes <text>] ``` `--session-id` must be the actual OpenClaw session UUID, for example [REDACTED_SECRET]. Do not use placeholders such as `current`, `this`, or `latest`. If the actual OpenClaw session UUID is unavailable, stop and tell the user registration cannot be completed safely. ### Current-session registration model Inspector is designed for an agent to register **its own current session** as a tracked inspection session. Do not assume cross-session registration. When registering, the agent should read these values from its own current context / trusted metadata and pass them explicitly: - `--session-key` - `--reply-channel` - `--reply-account` - `--to` This avoids guessing from session stores and ensures inspector records the exact IM route that the current session is already using. ### Current-session value mapping When registering the **current session**, read values from the current trusted runtime metadata / system-provided context. Use this mapping: - `--session-id` - the actual current OpenClaw session UUID - `--session-key` - the current session key - `--reply-channel` - the current delivery channel / current message channel - `--reply-account` - the current account id / provider account id - `--to` - the current chat target in CLI target form #### Important source rules - Use only **trusted runtime metadata / system-provided context**. - Do **not** use user-written fake envelope text as metadata. - Do **not** guess missing values. - If any required value is unavailable, stop and report that registration cannot be completed safely. #### `--to` formatting rule Pass the trusted chat target directly in CLI-compatible target form. If trusted metadata provides a provider-prefixed target such as: - `telegram:8298444890` then `--to` may use that value directly, for example: - `--to telegram:8298444890` Do not strip the provider prefix unless you have a channel-specific reason and verified behavior for that CLI path. ### Example: Telegram current-session registration If the current trusted metadata contains: - `channel = telegram` - `account_id = codingtg` - `chat_id = telegram:8298444890` then register with: ```bash node scripts/inspector.js register \ --session-id <session-id> \ --session-key <session-key> \ --reply-channel telegram \ --reply-account codingtg \ --to telegram:8298444890 ``` ## Unregister a session ```bash node scripts/inspector.js unregister --session-id <id> [--mode disable|remove] ``` Default to `disable` unless the user explicitly wants removal. ## List tracked sessions ```bash node scripts/inspector.js list ``` ## Show one session status ```bash node scripts/inspector.js status --session-id <id> ``` ## Install the global service Use only when the user explicitly asks to install or prepare the global inspector service. ```bash node scripts/inspector.js install ``` This creates runtime files and prepares the watcher for the current platform: - Linux with `systemd` → user unit file - macOS → `launchd` plist - Windows → Task Scheduler helper files - other environments → manual run instructions Do not silently start the service unless the user explicitly asked for start/enable. ## Diagnose failures ```bash node scripts/inspector.js doctor ``` When something fails, report: - which step failed - the exact error category if known - what succeeded before the failure - what the user should do next ## Safety rules - Do not start, enable, restart, stop, or uninstall the service unless the user explicitly asks. - Prefer updating registry/config over hard-coding values into scripts. ## Files to use - `scripts/inspector.js` - `scripts/watch-registered-sessions.js` - `scripts/common.js` - `references/config-fields.md`

path: scripts/common.js
content: #!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');

const homeDir = os.homedir();
const runtimeHome = process.env.SESSION_INSPECTOR_HOME || process.env.SESSION_INSPACTOR_HOME || path.join(homeDir, '.openclaw', 'inspector');
const registryPath = path.join(runtimeHome, 'registry.json');
const stateDir = path.join(runtimeHome, 'state');
const logsDir = path.join(runtimeHome, 'logs');
const systemdDir = path.join(runtimeHome, 'systemd');
const launchdDir = path.join(runtimeHome, 'launchd');
const windowsDir = path.join(runtimeHome, 'windows');
const configFile = path.join(runtimeHome, 'config.env');
const serviceName = 'openclaw-inspector';
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
const unitPath = path.join(unitDir, `${serviceName}.service`);
const launchAgentsDir = path.join(homeDir, 'Library', 'LaunchAgents');
const plistPath = path.join(launchAgentsDir, `${serviceName}.plist`);
const sessionsRoot = process.env.SESSION_INSPECTOR_SESSIONS_ROOT || process.env.SESSION_INSPACTOR_SESSIONS_ROOT || path.join(homeDir, '.openclaw', 'agents');
const openclawBin = process.env.OPENCLAW_BIN || 'openclaw';
const defaultConfig = {
  SCAN_INTERVAL_SECONDS: '30',
  DEFAULT_INACTIVE_THRESHOLD_SECONDS: '600',
  DEFAULT_COOLDOWN_SECONDS: '3600',
  DEFAULT_RUNNING_COOLDOWN_SECONDS: '600',
  DEFAULT_BLOCKED_COOLDOWN_SECONDS: '1800',
  DEFAULT_MAX_LAST_ACTIVITY_AGE_SECONDS: '43200',
  DEFAULT_MAX_STATUS_LOOKBACK_MESSAGES: '5',
  AGENT_CLI_TIMEOUT_SECONDS: '180',
};

function nowIso() {
  return new Date().toISOString();
}

function ensureDir(dir) {
  fs.mkdirSync(dir, { recursive: true });
}

function ensureRuntimeDirs() {
  [runtimeHome, stateDir, logsDir, systemdDir, launchdDir, windowsDir].forEach(ensureDir);
}

function readJson(file, fallback = null) {
  try {
    return JSON.parse(fs.readFileSync(file, 'utf8'));
  } catch (error) {
    if (fallback !== null) return fallback;
    throw error;
  }
}

function writeJson(file, value) {
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}

function ensureRegistry() {
  ensureRuntimeDirs();
  if (!fs.existsSync(registryPath)) {
    writeJson(registryPath, { sessions: [] });
  }
  const registry = readJson(registryPath, null);
  if (!registry || !Array.isArray(registry.sessions)) {
    throw new Error(`invalid registry json: ${registryPath}`);
  }
  return registry;
}

function isValidSessionUuid(value) {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '');
}

function parseEnvFile(file) {
  const result = {};
  if (!fs.existsSync(file)) return result;
  const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eq = trimmed.indexOf('=');
    if (eq <= 0) continue;
    const key = trimmed.slice(0, eq).trim();
    let value = trimmed.slice(eq + 1).trim();
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
      value = value.slice(1, -1);
    }
    result[key] = value;
  }
  return result;
}

function ensureConfigFile() {
  if (fs.existsSync(configFile)) return;
  const lines = Object.entries(defaultConfig).map(([k, v]) => `${k}=${v}`);
  fs.writeFileSync(configFile, `${lines.join('\n')}\n`, 'utf8');
}

function commandExists(command, args = ['--help']) {
  try {
    const result = spawnSync(command, args, { stdio: 'ignore' });
    return !result.error;
  } catch {
    return false;
  }
}

function shellQuote(value) {
  return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}

function parseTimestamp(value) {
  if (!value) return 0;
  const ms = Date.parse(String(value));
  return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
}

function extractText(content) {
  if (typeof content === 'string') return content.trim();
  if (Array.isArray(content)) {
    return content
      .filter((item) => item && typeof item === 'object' && item.type === 'text' && item.text)
      .map((item) => item.text)
      .join('\n')
      .trim();
  }
  return '';
}

function listImmediateSubdirs(dir) {
  try {
    return fs.readdirSync(dir, { withFileTypes: true })
      .filter((entry) => entry.isDirectory())
      .map((entry) => path.join(dir, entry.name));
  } catch {
    return [];
  }
}

function findSessionFile(sessionId) {
  for (const agentDir of listImmediateSubdirs(sessionsRoot)) {
    const candidate = path.join(agentDir, 'sessions', `${sessionId}.jsonl`);
    if (fs.existsSync(candidate)) return candidate;
  }
  return '';
}

function parseSessionFile(file, statusLookback = 5) {
  if (!file || !fs.existsSync(file)) return null;
  const stat = fs.statSync(file);
  const lastActivityEpoch = Math.floor(stat.mtimeMs / 1000);
  const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
  const statusRe = /^STATUS:\s*(DONE|WAITING|BLOCKED|RUNNING)\s*\|\s*(.*)$/i;
  let workdir = '';
  let command = '';
  let lastMessage = {};
  let latestStatus = {};
  let terminalStatus = '';
  let terminalTimestamp = '';
  let terminalEpoch = 0;
  const assistantTail = [];

  for (const rawLine of lines) {
    const raw = rawLine.trim();
    if (!raw) continue;
    let obj;
    try {
      obj = JSON.parse(raw);
    } catch {
      continue;
    }

    if (obj.type === 'session' && !workdir) {
      workdir = obj.cwd || workdir;
    }

    if (obj.type === 'custom' && obj.customType === 'openclaw:prompt-error') {
      terminalStatus = 'error';
      terminalTimestamp = obj.timestamp || terminalTimestamp;
      terminalEpoch = parseTimestamp(terminalTimestamp);
    }

    if (obj.type !== 'message') continue;
    const msg = obj.message || {};
    const role = msg.role;
    const timestamp = obj.timestamp || '';
    const stopReason = msg.stopReason || '';
    const text = extractText(msg.content);

    if (!command) {
      command = obj.command || msg.command || '';
    }

    if (role === 'assistant' || role === 'user' || role === 'toolResult') {
      lastMessage = { role, timestamp, epoch: parseTimestamp(timestamp), stop_reason: stopReason, text };
    }

    if (role === 'assistant' && text) {
      assistantTail.push({ timestamp, epoch: parseTimestamp(timestamp), stop_reason: stopReason, text });
      while (assistantTail.length > statusLookback) assistantTail.shift();
      const firstLine = text.split(/\r?\n/, 1)[0].trim();
      const match = statusRe.exec(firstLine);
      if (match) {
        latestStatus = {
          marker: match[1].toUpperCase(),
          summary: match[2].trim(),
          timestamp,
          epoch: parseTimestamp(timestamp),
          first_line: firstLine,
        };
      }
    }

    if (role === 'assistant' && !terminalStatus && ['aborted', 'error', 'failed'].includes(stopReason)) {
      terminalStatus = stopReason;
      terminalTimestamp = timestamp;
      terminalEpoch = parseTimestamp(timestamp);
    }
  }

  return {
    session_file: file,
    last_activity_epoch: lastActivityEpoch,
    last_activity: new Date(lastActivityEpoch * 1000).toISOString(),
    workdir,
    command,
    last_message: lastMessage,
    latest_status: latestStatus,
    assistant_tail: assistantTail,
    terminal_status: terminalStatus,
    terminal_timestamp: terminalTimestamp,
    terminal_epoch: terminalEpoch,
  };
}

function stateFileFor(sessionId) {
  return path.join(stateDir, `${sessionId}.json`);
}

function readState(sessionId) {
  return readJson(stateFileFor(sessionId), {});
}

function writeState(sessionId, value) {
  writeJson(stateFileFor(sessionId), value);
}

function mergeConfig() {
  return { ...defaultConfig, ...parseEnvFile(configFile), ...process.env };
}

module.exports = {
  fs,
  os,
  path,
  spawnSync,
  homeDir,
  runtimeHome,
  registryPath,
  stateDir,
  logsDir,
  systemdDir,
  launchdDir,
  windowsDir,
  configFile,
  serviceName,
  unitDir,
  unitPath,
  launchAgentsDir,
  plistPath,
  sessionsRoot,
  openclawBin,
  defaultConfig,
  nowIso,
  ensureDir,
  ensureRuntimeDirs,
  readJson,
  writeJson,
  ensureRegistry,
  isValidSessionUuid,
  parseEnvFile,
  ensureConfigFile,
  commandExists,
  shellQuote,
  parseTimestamp,
  extractText,
  findSessionFile,
  parseSessionFile,
  stateFileFor,
  readState,
  writeState,
  mergeConfig,
};


path: scripts/inspector.js
content: #!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const {
  runtimeHome,
  registryPath,
  stateDir,
  logsDir,
  systemdDir,
  launchdDir,
  windowsDir,
  configFile,
  serviceName,
  unitDir,
  unitPath,
  launchAgentsDir,
  plistPath,
  openclawBin,
  nowIso,
  ensureDir,
  ensureRuntimeDirs,
  writeJson,
  ensureRegistry,
  isValidSessionUuid,
  ensureConfigFile,
  commandExists,
  shellQuote,
  mergeConfig,
  readState,
} = require('./common');

const skillHome = path.resolve(__dirname, '..');
const watcherScript = path.join(skillHome, 'scripts', 'watch-registered-sessions.js');

function usage() {
  console.log(`Usage:
  node scripts/inspector.js register --session-id <id> --session-key <key> --reply-channel <channel> --reply-account <id> --to <target>
                                    [--agent <name>] [--workspace <path>]
                                    [--profile <name>] [--inactive <sec>] [--cooldown <sec>]
                                    [--running-cooldown <sec>] [--blocked-cooldown <sec>] [--notes <text>]
  node scripts/inspector.js unregister --session-id <id> [--mode disable|remove]
  node scripts/inspector.js list
  node scripts/inspector.js status --session-id <id>
  node scripts/inspector.js install
  node scripts/inspector.js doctor

Compatibility wrappers on Unix-like systems:
  scripts/inspector.js ...`);
}

function fail(message, code = 1) {
  console.error(`ERROR: ${message}`);
  process.exit(code);
}

function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i += 1) {
    const key = argv[i];
    if (!key.startsWith('--')) fail(`unknown arg: ${key}`);
    const name = key.slice(2);
    const value = argv[i + 1];
    if (value === undefined || value.startsWith('--')) fail(`missing value for ${key}`);
    args[name] = value;
    i += 1;
  }
  return args;
}

function maybeNumber(value, flagName) {
  if (value === undefined || value === '') return undefined;
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed < 0) {
    fail(`${flagName} must be a non-negative number`, 2);
  }
  return parsed;
}

function loadRegistry() {
  return ensureRegistry();
}

function saveRegistry(registry) {
  writeJson(registryPath, registry);
}

function hasCompleteDeliveryTriple(entry) {
  return Boolean(entry.delivery_channel && entry.delivery_account_id && entry.delivery_target);
}

function cmdRegister(argv) {
  const args = parseArgs(argv);
  const sessionId = args['session-id'];
  if (!sessionId) fail('--session-id is required', 2);
  if (['current', 'this', 'latest'].includes(sessionId)) {
    fail(`session_id must be an actual OpenClaw session UUID, not a placeholder like '${sessionId}'`, 2);
  }
  if (!isValidSessionUuid(sessionId)) {
    fail('session_id must be an OpenClaw session UUID like a13ec701-e0ef-4eac-b8cc-6159b3ff830c', 2);
  }
  if (!args['session-key']) fail('--session-key is required', 2);
  if (!args['reply-channel']) fail('--reply-channel is required', 2);
  if (!args['reply-account']) fail('--reply-account is required', 2);
  if (!args.to) fail('--to is required', 2);

  const registry = loadRegistry();
  const entry = {
    session_id: sessionId,
    registered_at: nowIso(),
    enabled: true,
    profile: args.profile || 'default',
  };

  entry.session_key = args['session-key'];
  if (args.agent) entry.agent = args.agent;
  if (args.workspace) entry.workspace = args.workspace;
  if (args.notes) entry.notes = args.notes;
  entry.delivery_channel = args['reply-channel'];
  entry.delivery_account_id = args['reply-account'];
  entry.delivery_target = args.to;

  const numericFields = [
    ['inactive', 'inactive_threshold_seconds'],
    ['cooldown', 'cooldown_seconds'],
    ['running-cooldown', 'running_cooldown_seconds'],
    ['blocked-cooldown', 'blocked_cooldown_seconds'],
  ];
  for (const [flag, field] of numericFields) {
    const value = maybeNumber(args[flag], `--${flag}`);
    if (value !== undefined) entry[field] = value;
  }

  if (!entry.session_key) {
    fail('registration requires --session-key from the agent current session metadata', 2);
  }
  if (!hasCompleteDeliveryTriple(entry)) {
    fail('registration requires complete delivery metadata from the agent current session context: --reply-channel, --reply-account, and --to', 2);
  }

  registry.sessions = registry.sessions.filter((item) => item.session_id !== sessionId);
  registry.sessions.push(entry);
  saveRegistry(registry);
  console.log(`OK: registered session ${sessionId}`);
}

function cmdUnregister(argv) {
  const args = parseArgs(argv);
  const sessionId = args['session-id'];
  const mode = args.mode || 'disable';
  if (!sessionId) fail('--session-id is required', 2);
  if (!['disable', 'remove'].includes(mode)) fail('--mode must be disable or remove', 2);

  const registry = loadRegistry();
  const existing = registry.sessions.find((item) => item.session_id === sessionId);
  if (!existing) fail(`session not registered: ${sessionId}`, 5);

  if (mode === 'remove') {
    registry.sessions = registry.sessions.filter((item) => item.session_id !== sessionId);
  } else {
    registry.sessions = registry.sessions.map((item) => (
      item.session_id === sessionId ? { ...item, enabled: false } : item
    ));
  }
  saveRegistry(registry);
  console.log(`OK: ${mode} session ${sessionId}`);
}

function cmdList() {
  const registry = loadRegistry();
  const rows = [...registry.sessions].sort((a, b) => String(b.registered_at || '').localeCompare(String(a.registered_at || '')));
  if (rows.length === 0) {
    console.log(`INFO: no registered sessions in ${registryPath}`);
    return;
  }
  for (const item of rows) {
    const state = item.enabled === false ? 'disabled' : 'enabled';
    console.log(`session=${item.session_id}\tstate=${state}\tprofile=${item.profile || 'default'}\tagent=${item.agent || ''}\tworkspace=${item.workspace || ''}\tdelivery=${item.delivery_channel || ''}:${item.delivery_target || ''}\taccount=${item.delivery_account_id || ''}`);
  }
}

function cmdStatus(argv) {
  const args = parseArgs(argv);
  const sessionId = args['session-id'];
  if (!sessionId) fail('--session-id is required', 2);
  const registry = loadRegistry();
  const existing = registry.sessions.find((item) => item.session_id === sessionId);
  if (!existing) fail(`session not registered: ${sessionId}`, 5);
  console.log(JSON.stringify(existing, null, 2));
  const state = readState(sessionId);
  if (Object.keys(state).length === 0) {
    console.log('INFO: no runtime state file yet');
  } else {
    console.log('--- state ---');
    console.log(JSON.stringify(state, null, 2));
  }
}

function createSystemdUnit() {
  ensureDir(unitDir);
  const nodeBin = process.execPath;
  const content = `[Unit]\nDescription=OpenClaw Inspector\nAfter=default.target\n\n[Service]\nType=simple\nEnvironment=PATH=${process.env.PATH || ''}\nEnvironment=SESSION_INSPECTOR_HOME=${runtimeHome}\nEnvironment=SESSION_INSPACTOR_HOME=${runtimeHome}\nEnvironmentFile=-${configFile}\nExecStart=${nodeBin} ${watcherScript}\nRestart=always\nRestartSec=60\n\n[Install]\nWantedBy=default.target\n`;
  fs.writeFileSync(unitPath, content, 'utf8');
  fs.writeFileSync(path.join(systemdDir, `${serviceName}.service`), content, 'utf8');
  return {
    manager: 'systemd',
    unit: unitPath,
    next_steps: [
      'systemctl --user daemon-reload',
      `systemctl --user enable --now ${serviceName}.service`,
    ],
  };
}

function createLaunchdPlist() {
  ensureDir(launchAgentsDir);
  const nodeBin = process.execPath;
  const plist = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n  <dict>\n    <key>Label</key><string>${serviceName}</string>\n    <key>ProgramArguments</key>\n    <array>\n      <string>${nodeBin}</string>\n      <string>${watcherScript}</string>\n    </array>\n    <key>EnvironmentVariables</key>\n    <dict>\n      <key>SESSION_INSPECTOR_HOME</key><string>${runtimeHome}</string>\n      <key>SESSION_INSPACTOR_HOME</key><string>${runtimeHome}</string>\n      <key>PATH</key><string>${process.env.PATH || ''}</string>\n    </dict>\n    <key>RunAtLoad</key><true/>\n    <key>KeepAlive</key><true/>\n    <key>WorkingDirectory</key><string>${runtimeHome}</string>\n    <key>StandardOutPath</key><string>${path.join(logsDir, 'launchd.stdout.log')}</string>\n    <key>StandardErrorPath</key><string>${path.join(logsDir, 'launchd.stderr.log')}</string>\n  </dict>\n</plist>\n`;
  fs.writeFileSync(plistPath, plist, 'utf8');
  fs.writeFileSync(path.join(launchdDir, `${serviceName}.plist`), plist, 'utf8');
  return {
    manager: 'launchd',
    unit: plistPath,
    next_steps: [
      `launchctl unload ${shellQuote(plistPath)} 2>/dev/null || true`,
      `launchctl load ${shellQuote(plistPath)}`,
    ],
  };
}

function createWindowsHints() {
  const cmdPath = path.join(windowsDir, 'watch-registered-sessions.cmd');
  const psPath = path.join(windowsDir, 'install-task.ps1');
  const nodeBin = process.execPath.replace(/\\/g, '/');
  const scriptPath = watcherScript.replace(/\\/g, '/');
  const cmd = `@echo off\r\nset SESSION_INSPECTOR_HOME=${runtimeHome.replace(/\\/g, '/')}\r\nset SESSION_INSPACTOR_HOME=${runtimeHome.replace(/\\/g, '/')}\r\n"${nodeBin}" "${scriptPath}" %*\r\n`;
  fs.writeFileSync(cmdPath, cmd, 'utf8');
  const ps = `$taskName = '${serviceName}'\n$action = New-ScheduledTaskAction -Execute '${nodeBin}' -Argument '${scriptPath}'\n$trigger = New-ScheduledTaskTrigger -AtLogOn\nRegister-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Description 'OpenClaw Inspector' -Force\n`;
  fs.writeFileSync(psPath, ps, 'utf8');
  return {
    manager: 'windows-task-scheduler',
    runner: cmdPath,
    helper: psPath,
    next_steps: [
      `powershell -ExecutionPolicy Bypass -File "${psPath}"`,
    ],
  };
}

function cmdInstall() {
  ensureRuntimeDirs();
  ensureConfigFile();
  ensureRegistry();

  let service;
  if (process.platform === 'linux' && commandExists('systemctl', ['--user', '--version'])) {
    service = createSystemdUnit();
  } else if (process.platform === 'darwin') {
    service = createLaunchdPlist();
  } else if (process.platform === 'win32') {
    service = createWindowsHints();
  } else {
    service = {
      manager: 'manual',
      next_steps: [`SESSION_INSPECTOR_HOME=${runtimeHome} ${process.execPath} ${watcherScript}`],
    };
  }

  console.log('OK: created runtime files');
  console.log(`runtime_home=${runtimeHome}`);
  console.log(`registry=${registryPath}`);
  console.log(`config=${configFile}`);
  console.log(`watcher=${watcherScript}`);
  console.log(`service_manager=${service.manager}`);
  if (service.unit) console.log(`unit=${service.unit}`);
  if (service.runner) console.log(`runner=${service.runner}`);
  if (service.helper) console.log(`helper=${service.helper}`);
  console.log('service not started');
  console.log('next steps (only if user explicitly requests):');
  for (const step of service.next_steps || []) {
    console.log(`  ${step}`);
  }
}

function safeExec(command, args) {
  const { spawnSync } = require('child_process');
  return spawnSync(command, args, { encoding: 'utf8' });
}

function cmdDoctor() {
  ensureRuntimeDirs();
  const registryExists = fs.existsSync(registryPath);
  let registryValid = false;
  try {
    if (registryExists) {
      ensureRegistry();
      registryValid = true;
    }
  } catch {
    registryValid = false;
  }
  const configExists = fs.existsSync(configFile);
  const openclawAvailable = commandExists(openclawBin, ['--help']);
  const info = {
    platform: process.platform,
    node: process.version,
    runtime_home: runtimeHome,
    registry: registryExists ? 'present' : 'missing',
    registry_json: registryExists ? (registryValid ? 'valid' : 'invalid') : 'n/a',
    config: configExists ? 'present' : 'missing',
    state_dir: fs.existsSync(stateDir) ? 'present' : 'missing',
    logs_dir: fs.existsSync(logsDir) ? 'present' : 'missing',
    openclaw: openclawAvailable ? 'available' : 'missing',
  };

  if (process.platform === 'linux' && commandExists('systemctl', ['--user', '--version'])) {
    info.service_manager = 'systemd';
    info.service_unit = unitPath;
    const result = safeExec('systemctl', ['--user', 'is-active', `${serviceName}.service`]);
    info.service_status = (result.stdout || result.stderr || '').trim() || 'inactive';
  } else if (process.platform === 'darwin') {
    info.service_manager = 'launchd';
    info.service_unit = plistPath;
    info.service_status = fs.existsSync(plistPath) ? 'installed' : 'not-installed';
  } else if (process.platform === 'win32') {
    info.service_manager = 'windows-task-scheduler';
    info.service_status = fs.existsSync(path.join(windowsDir, 'install-task.ps1')) ? 'helper-created' : 'not-installed';
  } else {
    info.service_manager = 'manual';
    info.service_status = 'manual';
  }

  const config = mergeConfig();
  info.scan_interval_seconds = Number(config.SCAN_INTERVAL_SECONDS || 30);
  console.log(JSON.stringify(info, null, 2));
}

function main() {
  const [, , command, ...rest] = process.argv;
  if (!command || ['-h', '--help', 'help'].includes(command)) {
    usage();
    return;
  }

  switch (command) {
    case 'register':
      cmdRegister(rest);
      return;
    case 'unregister':
      cmdUnregister(rest);
      return;
    case 'list':
      cmdList();
      return;
    case 'status':
      cmdStatus(rest);
      return;
    case 'install':
      cmdInstall();
      return;
    case 'doctor':
      cmdDoctor();
      return;
    default:
      fail(`unknown subcommand: ${command}`);
  }
}

main();


path: scripts/watch-registered-sessions.js
content: #!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const {
  registryPath,
  logsDir,
  openclawBin,
  nowIso,
  ensureRegistry,
  parseSessionFile,
  findSessionFile,
  readState,
  writeState,
  mergeConfig,
} = require('./common');

const skillHome = path.resolve(__dirname, '..');

function loadPromptFile(relativePath) {
  const fullPath = path.join(skillHome, relativePath);
  return fs.readFileSync(fullPath, 'utf8').trimEnd();
}

const RECOVERY_MESSAGE_TEMPLATE = loadPromptFile('prompts/recovery.txt');

const INACTIVE_MESSAGE_TEMPLATE = loadPromptFile('prompts/inactive.txt');

function usage() {
  console.log(`Usage:\n  node scripts/watch-registered-sessions.js [--once] [--dry-run] [--test] [--interval <sec>]\n\nCompatibility wrappers on Unix-like systems:\n  scripts/watch-registered-sessions.sh ...`);
}

function parseArgs(argv) {
  const args = {
    once: false,
    dryRun: false,
    test: false,
    interval: undefined,
  };
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    switch (arg) {
      case '--once':
        args.once = true;
        break;
      case '--dry-run':
        args.dryRun = true;
        break;
      case '--test':
        args.test = true;
        args.once = true;
        break;
      case '--interval':
        if (argv[i + 1] === undefined) throw new Error('missing value for --interval');
        args.interval = Number(argv[i + 1]);
        if (!Number.isFinite(args.interval) || args.interval <= 0) throw new Error('--interval must be a positive number');
        i += 1;
        break;
      case '-h':
      case '--help':
        args.help = true;
        break;
      default:
        throw new Error(`unknown arg: ${arg}`);
    }
  }
  return args;
}

function numberEnv(config, key, fallback) {
  const value = Number(config[key]);
  return Number.isFinite(value) ? value : fallback;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function scanRegisteredSessions(config) {
  const registry = ensureRegistry();
  const maxAge = numberEnv(config, 'DEFAULT_MAX_LAST_ACTIVITY_AGE_SECONDS', 43200);
  const statusLookback = numberEnv(config, 'DEFAULT_MAX_STATUS_LOOKBACK_MESSAGES', 5);
  const nowEpoch = Math.floor(Date.now() / 1000);
  const items = [];

  for (const entry of registry.sessions) {
    if (entry.enabled === false) continue;
    if (!entry.session_id) {
      items.push({ registry: entry, error: 'missing_session_id' });
      continue;
    }
    const sessionFile = findSessionFile(entry.session_id);
    const parsed = parseSessionFile(sessionFile, statusLookback);
    if (!parsed) {
      items.push({ registry: entry, error: 'session_file_not_found' });
      continue;
    }
    parsed.too_old = (nowEpoch - parsed.last_activity_epoch) > maxAge;
    items.push({ registry: entry, session: parsed });
  }

  return {
    generated_at: nowIso(),
    items,
  };
}

function mergedCandidate(registry, session, config) {
  return {
    ...session,
    session_id: registry.session_id,
    session_key: registry.session_key || '',
    delivery_channel: registry.delivery_channel || '',
    delivery_target: registry.delivery_target || '',
    delivery_account_id: registry.delivery_account_id || '',
    profile: registry.profile || 'default',
    registry_agent: registry.agent || '',
    inactive_threshold_seconds: registry.inactive_threshold_seconds ?? numberEnv(config, 'DEFAULT_INACTIVE_THRESHOLD_SECONDS', 1800),
    cooldown_seconds: registry.cooldown_seconds ?? numberEnv(config, 'DEFAULT_COOLDOWN_SECONDS', 3600),
    running_cooldown_seconds: registry.running_cooldown_seconds ?? numberEnv(config, 'DEFAULT_RUNNING_COOLDOWN_SECONDS', 600),
    blocked_cooldown_seconds: registry.blocked_cooldown_seconds ?? numberEnv(config, 'DEFAULT_BLOCKED_COOLDOWN_SECONDS', 3600),
    notes: registry.notes || '',
  };
}

function buildCandidateLists(scanJson, config) {
  const nowEpoch = Math.floor(Date.now() / 1000);
  const recovery = [];
  const inactive = [];
  const errors = [];

  for (const item of scanJson.items) {
    if (item.error) {
      errors.push(item);
      continue;
    }
    if (!item.session || item.session.too_old) continue;
    const candidate = mergedCandidate(item.registry, item.session, config);
    if (
      ['aborted', 'error', 'failed'].includes(candidate.terminal_status)
      && candidate.terminal_epoch > 0
      && candidate.terminal_epoch >= candidate.last_activity_epoch
      && (nowEpoch - candidate.terminal_epoch) >= 300
    ) {
      recovery.push(candidate);
    }
    if (candidate.last_activity_epoch > 0 && (nowEpoch - candidate.last_activity_epoch) >= candidate.inactive_threshold_seconds) {
      inactive.push(candidate);
    }
  }

  return { recovery, inactive, errors };
}

function markLatestStatuses(scanJson, config) {
  for (const item of scanJson.items) {
    const status = item.session?.latest_status?.marker;
    if (!status) continue;
    const sessionId = item.registry.session_id;
    const epoch = item.session.latest_status.epoch || 0;
    let cooldownUntil = 0;
    if (status === 'RUNNING') cooldownUntil = epoch + (item.registry.running_cooldown_seconds ?? numberEnv(config, 'DEFAULT_RUNNING_COOLDOWN_SECONDS', 600));
    if (status === 'BLOCKED') cooldownUntil = epoch + (item.registry.blocked_cooldown_seconds ?? numberEnv(config, 'DEFAULT_BLOCKED_COOLDOWN_SECONDS', 3600));
    writeState(sessionId, {
      session_id: sessionId,
      status: 'status_ack',
      reason: `assistant_reported_${String(status).toLowerCase()}`,
      workdir: item.session.workdir || '',
      session_file: item.session.session_file || '',
      last_activity: item.session.last_activity || '',
      last_activity_epoch: item.session.last_activity_epoch || 0,
      alert_type: 'status',
      status_ack: status,
      status_ack_at: epoch,
      cooldown_until: cooldownUntil,
      updated_at: nowIso(),
    });
  }
}

function shouldSkipCandidate(candidate, alertType) {
  const nowEpoch = Math.floor(Date.now() / 1000);
  const latestMarker = candidate.latest_status?.marker || '';
  const latestEpoch = candidate.latest_status?.epoch || 0;
  const lastActivityEpoch = candidate.last_activity_epoch || 0;

  if ((latestMarker === 'DONE' || latestMarker === 'WAITING') && latestEpoch > 0 && lastActivityEpoch <= latestEpoch) {
    return true;
  }
  if (latestMarker === 'RUNNING' && latestEpoch > 0 && lastActivityEpoch <= latestEpoch && latestEpoch + candidate.running_cooldown_seconds > nowEpoch) {
    return true;
  }
  if (latestMarker === 'BLOCKED' && latestEpoch > 0 && lastActivityEpoch <= latestEpoch && latestEpoch + candidate.blocked_cooldown_seconds > nowEpoch) {
    return true;
  }

  const state = readState(candidate.session_id);
  if (state.status_ack && state.status_ack_at) {
    if ((state.status_ack === 'DONE' || state.status_ack === 'WAITING') && lastActivityEpoch <= state.status_ack_at) return true;
    if ((state.status_ack === 'RUNNING' || state.status_ack === 'BLOCKED') && lastActivityEpoch <= state.status_ack_at && Number(state.cooldown_until || 0) > nowEpoch) return true;
  }

  if (
    state.status === 'notified'
    && Number(state.notified_at || 0) > 0
    && lastActivityEpoch <= Number(state.notified_at || 0)
    && state.alert_type === alertType
    && (Number(state.notified_at) + candidate.cooldown_seconds) > nowEpoch
  ) {
    return true;
  }

  return false;
}

function writeLog(file, lines) {
  fs.writeFileSync(file, `${lines.join('\n')}\n`, 'utf8');
}

function resolveDeliveryTarget(candidate) {
  const channel = String(candidate.delivery_channel || '').trim();
  const target = String(candidate.delivery_target || '').trim();
  const accountId = String(candidate.delivery_account_id || '').trim();
  if (!channel || !target || !accountId) return null;

  return {
    channel,
    target,
    accountId,
    source: 'registry',
  };
}

function dispatchNotification(candidate, alertType, args, config) {
  const sessionId = candidate.session_id;
  const logPrefix = path.join(logsDir, `${sessionId}.${alertType}`);
  const logFile = `${logPrefix}.log`;
  const agentLog = `${logPrefix}.agent.log`;
  const nowEpoch = Math.floor(Date.now() / 1000);
  const message = alertType === 'recovery' ? RECOVERY_MESSAGE_TEMPLATE : INACTIVE_MESSAGE_TEMPLATE;
  const delivery = resolveDeliveryTarget(candidate);

  if (args.dryRun || args.test) {
    console.log(`[dry-run] would inspect ${sessionId} via ${alertType}`);
    return;
  }

  writeLog(logFile, [
    `timestamp=${nowIso()}`,
    `session_id=${sessionId}`,
    `alert_type=${alertType}`,
    `workdir=${candidate.workdir || ''}`,
    `session_key=${candidate.session_key || ''}`,
    `delivery_channel=${delivery?.channel || ''}`,
    `delivery_target=${delivery?.target || ''}`,
    `delivery_account_id=${delivery?.accountId || ''}`,
    `delivery_source=${delivery?.source || ''}`,
  ]);

  if (!delivery) {
    writeState(sessionId, {
      session_id: sessionId,
      status: 'pending',
      reason: 'missing_delivery_target',
      workdir: candidate.workdir || '',
      session_file: candidate.session_file || '',
      last_activity: candidate.last_activity || '',
      last_activity_epoch: candidate.last_activity_epoch || 0,
      alert_type: alertType,
      log_file: logFile,
      updated_at: nowIso(),
    });
    writeLog(agentLog, [
      'exit_code=',
      'signal=',
      '--- stdout ---',
      '',
      '--- stderr ---',
      `ERROR: missing complete delivery triple in registry for session_id=${sessionId}`,
    ]);
    return;
  }

  writeState(sessionId, {
    session_id: sessionId,
    status: 'dispatching',
    reason: `${alertType}_delivery_in_progress`,
    workdir: candidate.workdir || '',
    session_file: candidate.session_file || '',
    last_activity: candidate.last_activity || '',
    last_activity_epoch: candidate.last_activity_epoch || 0,
    alert_type: alertType,
    notified_at: nowEpoch,
    updated_at: nowIso(),
  });

  const timeoutSeconds = numberEnv(config, 'AGENT_CLI_TIMEOUT_SECONDS', 180);
  const result = spawnSync(openclawBin, [
    'agent',
    '--session-id', sessionId,
    '--message', message,
    ...(delivery.accountId ? ['--reply-account', delivery.accountId] : []),
    '--reply-channel', delivery.channel,
    '--to', delivery.target,
    '--deliver',
    '--timeout', '120',
    '--thinking', 'minimal',
    '--json',
  ], {
    encoding: 'utf8',
    timeout: timeoutSeconds * 1000,
  });

  writeLog(agentLog, [
    `exit_code=${result.status ?? ''}`,
    `signal=${result.signal ?? ''}`,
    '--- stdout ---',
    result.stdout || '',
    '--- stderr ---',
    result.stderr || '',
  ]);

  if (result.error || result.status !== 0) {
    let reason = 'agent_cli_failed';
    let status = 'pending';
    const stderr = `${result.stderr || ''}\n${result.stdout || ''}`;
    if (result.error && result.error.code === 'ETIMEDOUT') reason = 'agent_cli_timeout';
    if (/Unknown sessionId/i.test(stderr)) {
      reason = 'unknown_session_id';
      status = 'abandoned';
    }
    writeState(sessionId, {
      session_id: sessionId,
      status,
      reason,
      workdir: candidate.workdir || '',
      session_file: candidate.session_file || '',
      last_activity: candidate.last_activity || '',
      last_activity_epoch: candidate.last_activity_epoch || 0,
      alert_type: alertType,
      log_file: logFile,
      updated_at: nowIso(),
    });
    return;
  }

  writeState(sessionId, {
    session_id: sessionId,
    status: 'notified',
    reason: `${alertType}_delivery_requested`,
    workdir: candidate.workdir || '',
    session_file: candidate.session_file || '',
    last_activity: candidate.last_activity || '',
    last_activity_epoch: candidate.last_activity_epoch || 0,
    alert_type: alertType,
    notified_at: nowEpoch,
    log_file: logFile,
    updated_at: nowIso(),
  });
}

function runScan(args) {
  const config = mergeConfig();
  const scanJson = scanRegisteredSessions(config);
  markLatestStatuses(scanJson, config);
  const candidates = buildCandidateLists(scanJson, config);

  if (args.test) {
    console.log(JSON.stringify({ scan: scanJson, candidates }, null, 2));
    return;
  }

  for (const item of candidates.recovery) {
    if (!shouldSkipCandidate(item, 'recovery')) dispatchNotification(item, 'recovery', args, config);
  }
  for (const item of candidates.inactive) {
    if (!shouldSkipCandidate(item, 'inactive')) dispatchNotification(item, 'inactive', args, config);
  }

  if (!args.dryRun) {
    console.log(`[${nowIso()}] scan complete`);
  }
}

async function main() {
  let args;
  try {
    args = parseArgs(process.argv.slice(2));
  } catch (error) {
    console.error(`ERROR: ${error.message}`);
    process.exit(1);
  }

  if (args.help) {
    usage();
    return;
  }

  ensureRegistry();
  while (true) {
    runScan(args);
    if (args.once) break;
    const config = mergeConfig();
    const interval = args.interval || numberEnv(config, 'SCAN_INTERVAL_SECONDS', 30);
    await sleep(interval * 1000);
  }
}

main().catch((error) => {
  console.error(`ERROR: ${error.stack || error.message}`);
  process.exit(1);
});


path: references/config-fields.md
content: # Inspector Runtime Design

## Separation of concerns

### Skill directory

Keep reusable code and documentation in:

- `~/.openclaw/skills/inspector/`

This directory should contain:
- `SKILL.md`
- `scripts/`
- `references/`

Do **not** keep mutable runtime registry, logs, or state here.

### Runtime directory

Keep mutable runtime data in:

- `~/.openclaw/inspector/`

Suggested layout:

```text
~/.openclaw/inspector/
├── registry.json
├── config.env
├── state/
├── logs/
└── systemd/
```

## registry.json schema

Recommended shape:

```json
{
  "sessions": [
    {
      "session_id": "a13ec701-e0ef-4eac-b8cc-6159b3ff830c",
      "session_key": "optional-stable-key",
      "delivery_channel": "telegram",
      "delivery_target": "8298444890",
      "delivery_account_id": "codingtg",
      "agent": "coding",
      "workspace": "/home/ubuntu/http-server/dev/dual-tls-socks5-tunnel",
      "registered_at": "2026-03-26T22:30:00+08:00",
      "enabled": true,
      "profile": "default",
      "inactive_threshold_seconds": 1800,
      "cooldown_seconds": 3600,
      "running_cooldown_seconds": 600,
      "blocked_cooldown_seconds": 3600,
      "notes": "optional note"
    }
  ]
}
```

## Field meanings

- `session_id`: primary session identifier for inspection
- `session_key`: optional alternate stable session reference
- `agent`: agent name
- `delivery_channel`: explicit IM channel used for visible delivery override
- `delivery_target`: explicit target passed to CLI `--to` for visible delivery
- `delivery_account_id`: explicit channel account / bot identity used for visible delivery
- `workspace`: associated repo/workdir
- `registered_at`: registry write time
- `enabled`: whether this session is actively inspected
- `profile`: named policy profile
- `inactive_threshold_seconds`: no-message threshold for inactive inspection
- `cooldown_seconds`: generic resend cooldown
- `running_cooldown_seconds`: cooldown after `STATUS: RUNNING`
- `blocked_cooldown_seconds`: cooldown after `STATUS: BLOCKED`
- `notes`: freeform operator note

## config.env suggestions

Example:

```bash
SCAN_INTERVAL_SECONDS=30
DEFAULT_INACTIVE_THRESHOLD_SECONDS=1800
DEFAULT_COOLDOWN_SECONDS=3600
DEFAULT_RUNNING_COOLDOWN_SECONDS=600
DEFAULT_BLOCKED_COOLDOWN_SECONDS=3600
DEFAULT_MAX_LAST_ACTIVITY_AGE_SECONDS=43200
DEFAULT_MAX_STATUS_LOOKBACK_MESSAGES=5
```

## Global service convention

Recommended service name / label:

- Linux systemd: `openclaw-inspector.service`
- macOS launchd label: `openclaw-inspector`
- Windows scheduled task name: `openclaw-inspector`

Recommended script entrypoint:

- `~/.openclaw/skills/inspector/scripts/watch-registered-sessions.js`

Runtime home should contain generated data only, not copied script entrypoints.

Recommended behavior:
- inspect only registry sessions
- ignore disabled entries
- maintain per-session state under `state/`
- write diagnosis and delivery logs under `logs/`
- use `[SYSTEM_INSPECTION]` as the prompt title

## Suggested state behavior

- `STATUS: DONE` → suppress until new activity
- `STATUS: WAITING` → suppress until new user-side progress
- `STATUS: BLOCKED` → apply blocked cooldown
- `STATUS: RUNNING` → apply running cooldown and preferably verify a live execution chain before trusting it


path: prompts/inactive.txt
content: [SYSTEM_INSPECTION]

This session has had no new messages for longer than the configured threshold. The original task may be stalled or still unfinished. First review the recent messages, the current workspace state, and any existing results, then decide the status based on whether the task can still be actively progressed in this inspection turn.

Follow these rules, and make sure your final visible reply is exactly one line in the format `STATUS: ...`:

1. If the task is already complete, reply:
   STATUS: DONE | <result summary>

2. If the task is clearly waiting for user input, approval, confirmation, a decision, or an external response, reply:
   STATUS: WAITING | <what is being waited on>

3. If the task is not yet complete, but work can still continue now, or you have resumed progress during this inspection turn, reply:
   STATUS: RUNNING | <what is currently being advanced + next step>

4. If the task is not yet complete, and you truly cannot continue right now, and it is not simply waiting for the user, reply:
   STATUS: BLOCKED | <blocking reason>

Requirements:
- Review recent messages, current workspace state, and existing outputs before deciding.
- You may call necessary tools and continue the original task within this inspection turn.
- Do not start unrelated new tasks.
- Do not repeat unnecessary initialization.
- If the status of background execution chains is unclear, or completion events seem unreliable, do not rely only on OpenClaw-managed status to judge task progress. You must immediately cross-check the real state from the host system using commands such as ps/pgrep for processes, grep/tail/cat for logs, journalctl for services, ls/stat for artifacts, and also verify with git diff, build results, and test results to confirm whether the task is actually complete.
- If the task can still be advanced, prefer continuing it and replying with `STATUS: RUNNING`.
- `I cannot confirm whether the old background execution chain is still alive` does not by itself mean `STATUS: BLOCKED`.
- If an old execution chain has died, but you have taken over and resumed progress in this inspection turn, you should reply with `STATUS: RUNNING`.
- Reply with `STATUS: BLOCKED` only when you confirm the task truly cannot continue right now.
- Reply with `STATUS: DONE` only when the task is actually complete.
- Reply with `STATUS: WAITING` only when the task is explicitly waiting on the user or an external dependency.
- Reply in the language that matches the conversation context.

Quick decision guide:
- Finished -> DONE
- Waiting on the user or an external dependency -> WAITING
- Can still continue / has resumed progress -> RUNNING
- Truly cannot continue now -> BLOCKED

Examples:
- STATUS: DONE | The Telegram card update flow has been fixed and verified successfully.
- STATUS: WAITING | Waiting for the user to provide test-account authorization.
- STATUS: RUNNING | Continuing to fix the original card update path; next step is verifying editMessageText and reply_markup updates.
- STATUS: BLOCKED | Required API credentials are missing, so validation cannot continue.


path: prompts/recovery.txt
content: [SYSTEM_INSPECTION]

This session may have been interrupted unexpectedly. The original task may be stalled, interrupted, or still unfinished. First review the recent messages, the current workspace state, and any existing results, then decide the status based on whether the task can still be actively progressed in this inspection turn.

Follow these rules, and make sure your final visible reply is exactly one line in the format `STATUS: ...`:

1. If the task is already complete, reply:
   STATUS: DONE | <result summary>

2. If the task is clearly waiting for user input, approval, confirmation, a decision, or an external response, reply:
   STATUS: WAITING | <what is being waited on>

3. If the task is not yet complete, but work can still continue now, or you have resumed progress during this inspection turn, reply:
   STATUS: RUNNING | <what is currently being advanced + next step>

4. If the task is not yet complete, and you truly cannot continue right now, and it is not simply waiting for the user, reply:
   STATUS: BLOCKED | <blocking reason>

Requirements:
- Review recent messages, current workspace state, and existing outputs before deciding.
- You may call necessary tools and continue the original task within this inspection turn.
- Do not start unrelated new tasks.
- Do not repeat unnecessary initialization.
- If the status of background execution chains is unclear, or completion events seem unreliable, do not rely only on OpenClaw-managed status to judge task progress. You must immediately cross-check the real state from the host system using commands such as ps/pgrep for processes, grep/tail/cat for logs, journalctl for services, ls/stat for artifacts, and also verify with git diff, build results, and test results to confirm whether the task is actually complete.
- If the task can still be advanced, prefer continuing it and replying with `STATUS: RUNNING`.
- `I cannot confirm whether the old background execution chain is still alive` does not by itself mean `STATUS: BLOCKED`.
- If an old execution chain has died, but you have taken over and resumed progress in this inspection turn, you should reply with `STATUS: RUNNING`.
- Reply with `STATUS: BLOCKED` only when you confirm the task truly cannot continue right now.
- Reply with `STATUS: DONE` only when the task is actually complete.
- Reply with `STATUS: WAITING` only when the task is explicitly waiting on the user or an external dependency.
- Reply in the language that matches the conversation context.

Quick decision guide:
- Finished -> DONE
- Waiting on the user or an external dependency -> WAITING
- Can still continue / has resumed progress -> RUNNING
- Truly cannot continue now -> BLOCKED

Examples:
- STATUS: DONE | The Telegram card update flow has been fixed and verified successfully.
- STATUS: WAITING | Waiting for the user to provide test-account authorization.
- STATUS: RUNNING | Continuing to fix the original card update path; next step is verifying editMessageText and reply_markup updates.
- STATUS: BLOCKED | Required API credentials are missing, so validation cannot continue.


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)