25 Commits

Author SHA1 Message Date
h z
a003416e56 Merge pull request 'fix: wake dedupe + inline slot context + complete contracts.tools' (#6) from fix/wake-dedupe-and-contracts into main 2026-05-20 14:48:06 +00:00
9ba591795b fix: wake dedupe + inline slot context + complete contracts.tools
Three issues making HF→agent wakeup unusable in practice, surfaced by
DinD sim end-to-end test (recruiter agent + slot for 招募 manager task):

1. **Plugin re-woke the same slot every 30s.** The inline runCheck only
   destructured agentId from scheduleCache.getAgentsWithDueSlots() and
   dropped the slots array, then called wakeAgent without recording the
   wake. The simplified inline scheduler also never PATCHes slot status
   server-side from not_started→ongoing, so the next 30s check sees the
   slot still due and wakes again. After 4 wakes the agent's wakeup
   session was full of WAKEUP_OK noise.

   Fix: keep slots in runCheck, add an in-memory wakedSlotKeys set
   keyed by (agentId, slotId|virtual_id|scheduled_at). Dedupe on this
   set; clear it inside the sync interval (fresh wake budget per sync).
   Server-side slot transition still TODO (requires re-introducing the
   CalendarScheduler class path or PATCH /calendar/slots/.../agent-update
   here); the dedupe at least stops the wake spam.

2. **Wakeup message had no slot context.** The wakeup body just said
   'follow hf-wakeup workflow' with no slot id/event_data/task_code.
   The agent then had to call harborforge_calendar_status to learn
   anything — which itself is broken in the simplified scheduler (it
   queries a CalendarScheduler instance that never gets created).

   Fix: pass dueSlots into wakeAgent and inline the highest-priority
   slot's {slot_id, scheduled_at, priority, slot_type, event_data} as
   a JSON block in the wakeup message. The agent reads event_data.
   task_code directly and routes via workflow_lookup without any
   round-trip. Per PLG-CAL-001 docs in hf-hangman-lab SKILL.md, this
   is the documented contract; we are bringing the message in line.

3. **contracts.tools listed 5 of the 9 registered tools.** Manifest had
   harborforge_status/telemetry/monitor_telemetry/calendar_status/
   calendar_complete. Code also registers calendar_abort, calendar_pause,
   calendar_resume, harborforge_restart_status. With the new OpenClaw
   plugin host enforcement (same gotcha that bit Meridian — see
   zhi/Meridian#2), undeclared tools are silently dropped from the
   agent's tool list, so abort/pause/resume cannot be called by the
   agent. plugin doctor was emitting:
   'plugin tool is undeclared (harbor-forge): harborforge_calendar_abort'
   for each missing tool.

   Fix: add the 4 missing tool names to contracts.tools.

Also use api.config as the primary config source in wakeAgent (current
public API), falling back to runtime.config.loadConfig() for older
hosts — same pattern as the Meridian fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:02:25 +01:00
c9f61419cb Merge docs/readme-refresh into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:57 +01:00
cc807484fc docs: refresh README — accuracy pass + HarborForge platform context
Verified against current code; fixed stale/inaccurate sections and
documented previously-undocumented features/flags/endpoints. Added a
"Part of the HarborForge platform" reference and role/port.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:50:01 +01:00
operator
917cb344cf chore: track ambient openclaw-sdk.d.ts (was caught by *.d.ts ignore)
The .gitignore was set up to skip tsc's compiled .d.ts output but it also
matched the hand-written ambient declarations file added in the previous
commit. Add a negation rule so that one file is tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:29:35 +00:00
operator
64a9c431bf chore: convert plugin to ESM and migrate to current openclaw plugin SDK
ESM conversion:
- plugin/package.json: add "type": "module".
- plugin/tsconfig.json: switch module/moduleResolution to "nodenext"; bump
  target to ES2022.
- All relative imports across plugin/ now carry .js extensions as required
  by Node ESM (nodenext module resolution).
- plugin/index.ts: replace `require('./calendar/schedule-cache')` with a
  proper top-level import; switch `from 'os'` to `from 'node:os'`.

Plugin SDK convention update:
- Wrap default export with definePluginEntry({ id, name, description,
  register }) per the current openclaw plugin authoring contract.
- Modernize plugin/openclaw.plugin.json: drop entry/version, add
  activation.onStartup so gateway_start fires for this plugin at boot,
  declare contracts.tools listing the five harborforge_* tools.
- Add a local plugin/openclaw-sdk.d.ts with ambient declarations for the
  focused subpaths (openclaw/plugin-sdk/plugin-entry,
  openclaw/plugin-sdk/core). We deliberately do NOT add openclaw as an
  npm devDependency: the installer's `npm install --omit=dev` step trips
  over openclaw's own (deeply nested) dependency graph when listed via
  file:.../openclaw, and the runtime contract is provided by the gateway
  loader anyway.
- The local PluginAPI interface is preserved (broader than the standard
  OpenClawPluginApi — it surfaces `version`/`runtime`/`spawn`); the
  register function is cast at the definePluginEntry boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:28:58 +00:00
h z
957bcbb4a8 Merge pull request 'feat: schedule cache, workflow-aligned prompts, dispatchInbound wakeup' (#5) from zhi-2026-04-18 into main
Reviewed-on: #5
2026-05-01 07:23:25 +00:00
operator
9195dc6bd1 feat: schedule cache, workflow-aligned prompts, dispatchInbound wakeup
1. ScheduleCache: local cache of today's schedule, synced every 5 min
   from HF backend via new getDaySchedule() API

2. Wakeup prompts updated to reference daily-routine skill workflows
   (task-handson, plan-schedule, slot-complete)

3. Agent wakeup via dispatchInboundMessageWithDispatcher (in-process)
   - Same mechanism as Discord plugin
   - Creates unique session per slot: agent:{agentId}:hf-calendar:slot-{slotId}
   - No WebSocket, CLI, or cron dependency
   - Verified working on test environment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:24:21 +00:00
operator
248adfaafd fix: use runtime API for version and agent list instead of subprocess
Use api.runtime.version for openclaw version and
api.runtime.config.loadConfig() for agent list. Eliminates the
periodic openclaw agents list subprocess that caused high CPU usage.
2026-04-16 15:53:20 +00:00
operator
e4ac7b7af3 fix: disable periodic openclaw agents list subprocess
Spawning a full openclaw CLI process every 30s to list agents is too
heavy — each invocation loads all plugins (~16s) and hangs until killed.
Return empty array for now until a lighter mechanism is available.
2026-04-16 15:26:55 +00:00
operator
2088cd12b4 fix: use OPENCLAW_SERVICE_VERSION for real version and increase agent list timeout
api.version returns plugin API version (0.2.0), not the openclaw release
version. Use OPENCLAW_SERVICE_VERSION env var set by the gateway instead.
Also increase listOpenClawAgents timeout from 15s to 30s since plugin
loading takes ~16s on T2.
2026-04-16 15:12:35 +00:00
h z
58a800a1aa Merge pull request 'HarborForge.OpenclawPlugin: dev-2026-03-29 -> main' (#4) from dev-2026-03-29 into main
Reviewed-on: #4
2026-04-05 22:09:30 +00:00
b81125db0d fix: use patch for calendar slot agent updates 2026-04-04 18:29:50 +00:00
7f86073fe7 fix: send calendar heartbeat as post 2026-04-04 17:58:59 +00:00
f3a38d6455 fix: verify plugin config during install 2026-04-04 17:30:56 +00:00
bcd47d8b39 feat: populate monitor agents from openclaw list 2026-04-04 08:55:00 +00:00
9b13c6b7aa fix: only fill missing plugin config values 2026-04-04 08:33:38 +00:00
3f8859424c refactor: remove monitor legacy compatibility 2026-04-04 08:05:50 +00:00
2262f32a0b feat: pass config overrides to managed monitor 2026-04-04 07:54:07 +00:00
ff5c07a38c fix: use explicit monitor repository url 2026-04-04 07:28:32 +00:00
485f8e117b fix: build monitor from cmd entrypoint 2026-04-04 07:00:03 +00:00
e6e1c5395b fix: install managed monitor from temp clone 2026-04-04 06:54:18 +00:00
038862ef8c refactor: manage monitor via gateway hooks 2026-04-04 00:44:33 +00:00
zhi
3b0ea0ad12 PLG-CAL-004: Implement ScheduledGatewayRestart handling in plugin
- Add state persistence (persistState/restoreState) for recovery after restart
- Add handleScheduledGatewayRestart method that:
  - Persists current scheduler state to disk
  - Sends final heartbeat to backend before shutdown
  - Stops the calendar scheduler (pauses scheduled tasks)
- Add isRestartPending flag to prevent new slot processing during restart
- Add isScheduledGatewayRestart helper to detect restart events
- Update scheduler to detect and handle ScheduledGatewayRestart events
- Add new tools: harborforge_restart_status, harborforge_calendar_pause/resume
- Export isRestartPending and getStateFilePath methods
- Bump plugin version to 0.3.1
2026-04-01 09:41:02 +00:00
zhi
24c4a7ad14 PLG-CAL-003 fix deferred slot replanning 2026-04-01 08:52:11 +00:00
44 changed files with 1363 additions and 1832 deletions

11
.gitignore vendored
View File

@@ -1,5 +1,8 @@
plugin/node_modules/ plugin/node_modules/
plugin/*.js plugin/dist/
plugin/*.js.map plugin/**/*.js
plugin/*.d.ts plugin/**/*.js.map
plugin/*.d.ts.map plugin/**/*.d.ts
plugin/**/*.d.ts.map
# Hand-written ambient declarations are tracked; only compiled .d.ts above is ignored.
!plugin/openclaw-sdk.d.ts

196
README.md
View File

@@ -1,80 +1,107 @@
# HarborForge OpenClaw Plugin # HarborForge OpenClaw Plugin
OpenClaw 插件:向 HarborForge Monitor 暴露 OpenClaw 侧元数据,并提供可选的本地桥接能力;安装时也可顺带安装 `hf` CLI OpenClaw plugin that exposes OpenClaw-side metadata to the HarborForge Monitor, provides an optional local Monitor bridge, drives the HarborForge Calendar scheduler, and can optionally install the `hf` CLI.
## 当前状态 Part of the [HarborForge](../README.md) platform.
- 插件注册名:`harbor-forge` - Role: OpenClaw integration layer for HarborForge (registered plugin id: `harbor-forge`).
- 旧 sidecar `server/` 架构已移除 - Talks to the HarborForge backend (`backendUrl`, default `https://monitor.hangman-lab.top`) for Calendar APIs.
- 监控桥接走本地 `monitor_port` - Talks to a local HarborForge.Monitor bridge over `127.0.0.1:<monitor_port>` (no fixed default; commonly `9100`).
- 安装脚本支持 `--install-cli`
- `skills/hf/` 仅在 `--install-cli` 时一并安装
## 项目结构 ## Current State
- Plugin registration id: `harbor-forge` (was `harborforge-monitor`)
- Plugin version: `0.2.0` (package manifests); telemetry reports `pluginVersion` `0.3.1`
- Legacy sidecar `server/` architecture removed — telemetry is served directly by the plugin
- Monitor bridge runs over the local `monitor_port`
- Calendar scheduler integration (PLG-CAL-001 / 002 / 004)
- Installer supports `--install-cli` and an optional managed Monitor (`--install-monitor`)
- `skills/hf/` is installed only with `--install-cli`
## Project Structure
```text ```text
HarborForge.OpenclawPlugin/ HarborForge.OpenclawPlugin/
├── package.json ├── package.json
├── README.md ├── README.md
├── docs/ # design notes (calendar, monitor connector)
├── plugin/ ├── plugin/
│ ├── openclaw.plugin.json │ ├── openclaw.plugin.json # plugin manifest + config schema
│ ├── index.ts │ ├── index.ts # plugin entry, tool registration
│ ├── tsconfig.json
│ ├── core/ │ ├── core/
│ │ ├── live-config.ts │ │ ├── config.ts # config defaults / resolution
│ │ ── monitor-bridge.ts │ │ ── managed-monitor.ts # optionally spawn HarborForge.Monitor
│ │ ├── monitor-bridge.ts # client for the Monitor bridge
│ │ └── openclaw-agents.ts # enumerate OpenClaw agents
│ ├── calendar/ # Calendar scheduler + bridge
│ │ ├── index.ts
│ │ ├── scheduler.ts
│ │ ├── calendar-bridge.ts
│ │ └── types.ts
│ ├── hooks/
│ │ ├── gateway-start.ts
│ │ └── gateway-stop.ts
│ └── package.json │ └── package.json
├── skills/ ├── skills/
│ └── hf/ │ └── hf/
│ └── SKILL.md │ └── SKILL.md # installed only with --install-cli
└── scripts/ └── scripts/
└── install.mjs └── install.mjs
``` ```
## 安装 ## Installation
### 普通安装 ### Standard Install
```bash ```bash
node scripts/install.mjs node scripts/install.mjs
``` ```
这会: This will:
- 构建并安装 OpenClaw 插件 - Build and install the OpenClaw plugin
- 复制常规 skills - Copy regular skills
- **不会**安装 `hf` 二进制 - **Not** install the `hf` binary
- **不会**复制 `skills/hf/` - **Not** copy `skills/hf/`
### 安装插件 + `hf` CLI ### Plugin + `hf` CLI
```bash ```bash
node scripts/install.mjs --install-cli node scripts/install.mjs --install-cli
``` ```
这会额外: This additionally:
- 构建 `HarborForge.Cli` - Builds `HarborForge.Cli` (`go build ./cmd/hf`)
- 安装 `hf` `~/.openclaw/bin/hf` - Installs `hf` to `<openclaw>/bin/hf` (default `~/.openclaw/bin/hf`) and `chmod +x`
- `chmod +x ~/.openclaw/bin/hf` - Copies `skills/hf/` into the OpenClaw skills directory
- 复制 `skills/hf/` 到 OpenClaw profile skills 目录
### 常用选项 ### Common Options
```bash ```bash
# 仅构建 # Build only (no install / config)
node scripts/install.mjs --build-only node scripts/install.mjs --build-only
# 指定 OpenClaw profile # Install only, skip dependency checks
node scripts/install.mjs --skip-check
# Specify OpenClaw profile path (also honors OPENCLAW_PATH env)
node scripts/install.mjs --openclaw-profile-path /custom/path/.openclaw node scripts/install.mjs --openclaw-profile-path /custom/path/.openclaw
# 详细日志 # Build and install a managed HarborForge.Monitor binary alongside the plugin
node scripts/install.mjs --install-monitor yes --monitor-branch main
# Verbose logs
node scripts/install.mjs --verbose node scripts/install.mjs --verbose
# 卸载 # Uninstall (plugin, config entries, hf binary, managed monitor)
node scripts/install.mjs --uninstall node scripts/install.mjs --uninstall
``` ```
## 配置 The installer also updates OpenClaw config (`plugins.load.paths`, `plugins.allow`, `plugins.entries.harbor-forge.enabled`) via `openclaw config`.
编辑 `~/.openclaw/openclaw.json` ## Configuration
Edit `~/.openclaw/openclaw.json`:
```json ```json
{ {
@@ -90,7 +117,9 @@ node scripts/install.mjs --uninstall
"monitor_port": 9100, "monitor_port": 9100,
"reportIntervalSec": 30, "reportIntervalSec": 30,
"httpFallbackIntervalSec": 60, "httpFallbackIntervalSec": 60,
"logLevel": "info" "logLevel": "info",
"calendarEnabled": true,
"calendarHeartbeatIntervalSec": 60
} }
} }
} }
@@ -98,53 +127,72 @@ node scripts/install.mjs --uninstall
} }
``` ```
> 说明:`monitor_port` 是当前主字段;为兼容旧配置,插件仍接受 `monitorPort`。 Then restart:
然后重启:
```bash ```bash
openclaw gateway restart openclaw gateway restart
``` ```
## 配置项 ## Config Options
| 选项 | 类型 | 默认值 | 说明 | | Option | Type | Default | Description |
|------|------|--------|------| |--------|------|---------|-------------|
| `enabled` | boolean | `true` | 是否启用插件 | | `enabled` | boolean | `true` | Enable the plugin |
| `backendUrl` | string | `https://monitor.hangman-lab.top` | HarborForge Monitor 后端地址 | | `backendUrl` | string | `https://monitor.hangman-lab.top` | HarborForge backend base URL (Monitor + Calendar APIs) |
| `identifier` | string | 主机名 | 服务器标识符 | | `identifier` | string | hostname | Server / claw identifier |
| `apiKey` | string | 无 | HarborForge Monitor 生成的服务器 API Key | | `apiKey` | string | (none) | Server API key from the HarborForge Monitor admin panel |
| `monitor_port` | number | 无 | 本地桥接端口;插件通过 `127.0.0.1:<monitor_port>` 与 HarborForge.Monitor 通信 | | `monitor_port` | number | (none) | Local bridge port; plugin talks to HarborForge.Monitor via `127.0.0.1:<monitor_port>` |
| `reportIntervalSec` | number | `30` | 报告间隔(秒) | | `reportIntervalSec` | number | `30` | Metadata push interval (seconds) |
| `httpFallbackIntervalSec` | number | `60` | HTTP 回退间隔(秒) | | `httpFallbackIntervalSec` | number | `60` | HTTP heartbeat interval when WS unavailable |
| `logLevel` | string | `info` | 日志级别:`debug` / `info` / `warn` / `error` | | `logLevel` | string | `info` | Log level: `debug` / `info` / `warn` / `error` |
| `calendarEnabled` | boolean | `true` | Enable Calendar scheduler integration (PLG-CAL-001) |
| `calendarHeartbeatIntervalSec` | number | `60` | Calendar heartbeat interval (seconds) |
| `calendarApiKey` | string | (none) | API key for Calendar API auth; falls back to `apiKey` / `X-Agent-ID` |
| `managedMonitor` | string | (none) | Absolute path to a HarborForge.Monitor binary; if set, gateway start/stop hooks spawn/stop it |
## 本地桥接说明 ## Local Monitor Bridge
当插件配置了 `monitor_port`,并且 HarborForge.Monitor 也使用相同的 `MONITOR_PORT` 时: When the plugin has `monitor_port` configured and HarborForge.Monitor uses the same `MONITOR_PORT`:
- Monitor `127.0.0.1:<MONITOR_PORT>` 提供本地桥接服务 - Monitor serves a local bridge on `127.0.0.1:<MONITOR_PORT>`
- 插件可探测 `GET /health` - The plugin probes `GET /health`
- 插件工具 `harborforge_monitor_telemetry` 可读取 `GET /telemetry` - The plugin tool `harborforge_monitor_telemetry` reads `GET /telemetry`
- 如果桥接端口未配置或不可达,插件仍可正常运行 - The plugin pushes OpenClaw metadata (version, plugin version, agents) via `POST /openclaw` on the `reportIntervalSec` cadence, enriching Monitor heartbeats
- If the bridge port is unconfigured or unreachable, the plugin still works normally
也就是说,这条链路是**可选增强**,不是插件启动或 Monitor 心跳的前置条件。 This link is an **optional enhancement**, not a precondition for the plugin to start or for Monitor heartbeats.
## 插件提供的信息 ## Managed Monitor
### OpenClaw 元数据 If `managedMonitor` points to an installed HarborForge.Monitor binary, the `gateway_start` hook spawns it (passing `--backend-url`, `--identifier`, `--api-key`, `--monitor-port`, `--report-interval`, `--log-level` from the plugin config) and `gateway_stop` terminates it. Use `install.mjs --install-monitor yes` to build and wire this automatically.
- OpenClaw version
- plugin version
- 标识符 / 主机名
- 时间戳
### 系统快照 ## Calendar Scheduler
- uptime
- memory total/free/used/usagePercent
- load avg1/avg5/avg15
- platform
## 开发 When `calendarEnabled` is true, on gateway start the plugin starts a Calendar scheduler that heartbeats the backend (`/calendar/agent/heartbeat`, `/calendar/agent/status`, `/calendar/agent/notify`) to receive and run scheduled TimeSlots, waking/spawning agents via the OpenClaw `spawn` API (with a notification fallback). Scheduler state is persisted to a state file; gateway restarts can be requested by the backend (PLG-CAL-004).
## Tools Provided
| Tool | Description |
|------|-------------|
| `harborforge_status` | Plugin status, resolved config, Monitor bridge health, calendar status, telemetry snapshot |
| `harborforge_telemetry` | Current system telemetry snapshot from this host |
| `harborforge_monitor_telemetry` | Query the Monitor bridge for host hardware telemetry |
| `harborforge_calendar_status` | Calendar scheduler status and current slot |
| `harborforge_calendar_complete` | Complete the current calendar slot with actual duration |
| `harborforge_calendar_abort` | Abort the current calendar slot |
| `harborforge_calendar_pause` | Pause the current calendar slot |
| `harborforge_calendar_resume` | Resume the paused calendar slot |
| `harborforge_restart_status` | Check whether a gateway restart is pending |
### Telemetry Snapshot Fields
- `identifier`, `hostname`, `platform`, `timestamp`
- `uptime`
- `memory`: `total` / `free` / `used` / `usagePercent`
- `load`: `avg1` / `avg5` / `avg15`
- `openclaw`: `version` / `pluginVersion`
## Development
```bash ```bash
cd plugin cd plugin
@@ -152,14 +200,16 @@ npm install
npm run build npm run build
``` ```
## 依赖 The build runs `tsc` and emits `dist/` (`dist/index.js` is the plugin entry).
## Dependencies
- Node.js 18+ - Node.js 18+
- OpenClaw Gateway - OpenClaw Gateway
- Go 1.20+(仅 `--install-cli` 需要) - Go 1.20+ (only for `--install-cli` / `--install-monitor`)
## 相关提示 ## Tips
- 安装 `hf` 后,建议把 `~/.openclaw/bin` 加到 `PATH` - After installing `hf`, add `~/.openclaw/bin` to your `PATH`
- Agent 使用 `hf` 时,优先试 `hf --help-brief` - When an agent uses `hf`, try `hf --help-brief` first
- 完整命令树看 `hf --help` - For the full command tree, see `hf --help`

118
REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,118 @@
# CalendarScheduler Refactor Plan (v2)
> Updated 2026-04-19 based on architecture discussion with hang
## Current Issues
1. `process.env.AGENT_ID` doesn't exist in plugins subprocess — always 'unknown'
2. Heartbeat is per-agent but should be per-claw-instance (global)
3. Scheduler only handles one agent — should manage all agents on this instance
4. wakeAgent used api.spawn (non-existent) → now uses dispatchInboundMessage (verified)
## Target Design
### Plugin State
```typescript
// Local schedule cache: { agentId → [slots] }
const schedules: Map<string, CalendarSlotResponse[]> = new Map();
```
### Sync Flow (every 5 min)
```
1. GET /calendar/sync?claw_identifier=xxx
- First call: server returns full { agentId → [slots] }
- Subsequent: server returns diff since last sync
2. Update local schedules map
3. Scan schedules for due slots:
for each agentId in schedules:
if has slot where scheduled_at <= now && status == not_started:
getAgentStatus(agentId, clawIdentifier) → busy?
if not busy → wakeAgent(agentId)
```
### Heartbeat (every 60s)
Simplified to liveness ping only:
```
POST /monitor/server/heartbeat
claw_identifier: xxx
→ server returns empty/ack
```
No slot data in heartbeat response.
### Wake Flow
```
dispatchInboundMessage:
SessionKey: agent:{agentId}:hf-wakeup
Body: "You have due slots. Follow the hf-wakeup workflow of skill hf-hangman-lab to proceed. Only reply WAKEUP_OK in this session."
Agent reads workflow → calls hf tools → sets own status to busy
```
### Agent ID Resolution
- **Sync**: agentId comes from server response (dict keys)
- **Wake**: agentId from local schedules dict key
- **Tool calls by agent**: agentId from tool ctx (same as padded-cell)
## Backend API Changes Needed
### New: GET /calendar/sync
```
GET /calendar/sync?claw_identifier=xxx
Headers: X-Claw-Identifier
Response (first call):
{
"full": true,
"schedules": {
"developer": [slot1, slot2, ...],
"operator": [slot3, ...]
},
"syncToken": "abc123"
}
Response (subsequent, with ?since=abc123):
{
"full": false,
"diff": [
{ "op": "add", "agent": "developer", "slot": {...} },
{ "op": "update", "agent": "developer", "slotId": 5, "patch": {...} },
{ "op": "remove", "agent": "operator", "slotId": 3 }
],
"syncToken": "def456"
}
```
### Existing: POST /calendar/agent/status
Keep as-is but ensure it accepts agentId + clawIdentifier as params:
```
POST /calendar/agent/status
{ agent_id, claw_identifier, status }
```
## Implementation Order
1. Backend: Add /calendar/sync endpoint
2. Plugin: Replace CalendarBridgeClient single-agent design with multi-agent
3. Plugin: Replace CalendarScheduler with new sync+check loop
4. Plugin: wakeAgent uses dispatchInboundMessage (done)
5. Plugin: Tool handlers get agentId from ctx (like padded-cell)
## Files to Change
### Backend (HarborForge.Backend)
- New route: `/calendar/sync`
- New service: schedule diff tracking per claw_identifier
### Plugin
- `plugin/calendar/calendar-bridge.ts` — remove agentId binding, add sync()
- `plugin/calendar/scheduler.ts` — rewrite to multi-agent sync+check
- `plugin/calendar/schedule-cache.ts` — already exists, adapt to multi-agent
- `plugin/index.ts` — update wakeAgent, getAgentStatus to accept agentId

View File

@@ -1,124 +0,0 @@
/**
* HarborForge Calendar Bridge Client
*
* PLG-CAL-001: Handles HTTP communication between the OpenClaw plugin
* and the HarborForge backend for Calendar heartbeat and slot updates.
*
* Request authentication:
* • X-Agent-ID header — set to process.env.AGENT_ID
* • X-Claw-Identifier header — set to the server's claw_identifier
* (from plugin config or hostname fallback)
*
* Base URL:
* Derived from plugin config: backendUrl + "/calendar"
* Default backendUrl: "https://monitor.hangman-lab.top"
*
* Endpoints used:
* GET /calendar/agent/heartbeat — fetch pending slots
* PATCH /calendar/slots/{id}/agent-update — update real slot status
* PATCH /calendar/slots/virtual/{vid}/agent-update — update virtual slot status
*
* References:
* • NEXT_WAVE_DEV_DIRECTION.md §6.1 (Heartbeat flow)
* • HarborForge.Backend/app/services/agent_heartbeat.py (BE-AGT-001)
*/
import { CalendarHeartbeatResponse, CalendarSlotResponse, SlotAgentUpdate } from './types';
export interface CalendarBridgeConfig {
/** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */
backendUrl: string;
/** Server/claw identifier (from plugin config or hostname fallback) */
clawIdentifier: string;
/** OpenClaw agent ID ($AGENT_ID), set at agent startup */
agentId: string;
/** HTTP request timeout in milliseconds (default: 5000) */
timeoutMs?: number;
}
export declare class CalendarBridgeClient {
private baseUrl;
private config;
private timeoutMs;
constructor(config: CalendarBridgeConfig);
/**
* Fetch today's pending calendar slots for this agent.
*
* Heartbeat flow (§6.1):
* 1. Plugin sends heartbeat every minute
* 2. Backend returns slots where status is NotStarted or Deferred
* AND scheduled_at <= now
* 3. Plugin selects highest-priority slot (if any)
* 4. For remaining slots, plugin sets status = Deferred + priority += 1
*
* @returns CalendarHeartbeatResponse or null if the backend is unreachable
*/
heartbeat(): Promise<CalendarHeartbeatResponse | null>;
/**
* Update a real (materialized) slot's status after agent execution.
*
* Used by the plugin to report:
* - Slot attended (attended=true, started_at=now, status=Ongoing)
* - Slot finished (actual_duration set, status=Finished)
* - Slot deferred (status=Deferred, priority += 1)
* - Slot aborted (status=Aborted)
*
* @param slotId Real slot DB id
* @param update Status update payload
* @returns true on success, false on failure
*/
updateSlot(slotId: number, update: SlotAgentUpdate): Promise<boolean>;
/**
* Update a virtual (plan-generated) slot's status after agent execution.
*
* When updating a virtual slot, the backend first materializes it
* (creates a real TimeSlot row), then applies the update.
* The returned slot will have a real id on subsequent calls.
*
* @param virtualId Virtual slot id in format "plan-{plan_id}-{date}"
* @param update Status update payload
* @returns Updated CalendarSlotResponse on success, null on failure
*/
updateVirtualSlot(virtualId: string, update: SlotAgentUpdate): Promise<CalendarSlotResponse | null>;
/**
* Report the agent's current runtime status to HarborForge.
*
* Used to push agent status transitions:
* idle → busy / on_call (when starting a slot)
* busy / on_call → idle (when finishing a slot)
* → exhausted (on rate-limit / billing error, with recovery_at)
* → offline (after 2 min with no heartbeat)
*
* @param status New agent status
* @param recoveryAt ISO timestamp for expected Exhausted recovery (optional)
* @param exhaustReason "rate_limit" | "billing" (required if status=exhausted)
*/
reportAgentStatus(params: {
status: 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
recoveryAt?: string;
exhaustReason?: 'rate_limit' | 'billing';
}): Promise<boolean>;
private fetchJson;
private postBoolean;
}
export interface CalendarPluginConfig {
/** Backend URL for calendar API (overrides monitor backendUrl) */
calendarBackendUrl?: string;
/** Server identifier (overrides auto-detected hostname) */
identifier?: string;
/** Agent ID from OpenClaw ($AGENT_ID) */
agentId: string;
/** HTTP timeout for calendar API calls (default: 5000) */
timeoutMs?: number;
}
/**
* Build a CalendarBridgeClient from the OpenClaw plugin API context.
*
* @param api OpenClaw plugin API (register() receives this)
* @param fallbackUrl Fallback backend URL if not configured
* @param agentId $AGENT_ID from OpenClaw environment
*/
export declare function createCalendarBridgeClient(api: {
config?: Record<string, unknown>;
logger?: {
debug?: (...args: unknown[]) => void;
};
}, fallbackUrl: string, agentId: string): CalendarBridgeClient;
//# sourceMappingURL=calendar-bridge.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"calendar-bridge.d.ts","sourceRoot":"","sources":["calendar-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAEL,yBAAyB,EACzB,oBAAoB,EACpB,eAAe,EAEhB,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,oBAAoB;IACnC,4EAA4E;IAC5E,UAAU,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,cAAc,EAAE,MAAM,CAAC;IACvB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,MAAM,CAAiC;IAC/C,OAAO,CAAC,SAAS,CAAS;gBAEd,MAAM,EAAE,oBAAoB;IASxC;;;;;;;;;;;OAWG;IACG,SAAS,IAAI,OAAO,CAAC,yBAAyB,GAAG,IAAI,CAAC;IAwB5D;;;;;;;;;;;;OAYG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,OAAO,CAAC;IAK3E;;;;;;;;;;OAUG;IACG,iBAAiB,CACrB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAkBvC;;;;;;;;;;;;OAYG;IACG,iBAAiB,CAAC,MAAM,EAAE;QAC9B,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;QAC9D,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,aAAa,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;KAC1C,GAAG,OAAO,CAAC,OAAO,CAAC;YAcN,SAAS;YAsBT,WAAW;CAsB1B;AASD,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAA;KAAE,CAAA;CAAE,EAC5F,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,oBAAoB,CActB"}

View File

@@ -1,214 +0,0 @@
"use strict";
/**
* HarborForge Calendar Bridge Client
*
* PLG-CAL-001: Handles HTTP communication between the OpenClaw plugin
* and the HarborForge backend for Calendar heartbeat and slot updates.
*
* Request authentication:
* • X-Agent-ID header — set to process.env.AGENT_ID
* • X-Claw-Identifier header — set to the server's claw_identifier
* (from plugin config or hostname fallback)
*
* Base URL:
* Derived from plugin config: backendUrl + "/calendar"
* Default backendUrl: "https://monitor.hangman-lab.top"
*
* Endpoints used:
* GET /calendar/agent/heartbeat — fetch pending slots
* PATCH /calendar/slots/{id}/agent-update — update real slot status
* PATCH /calendar/slots/virtual/{vid}/agent-update — update virtual slot status
*
* References:
* • NEXT_WAVE_DEV_DIRECTION.md §6.1 (Heartbeat flow)
* • HarborForge.Backend/app/services/agent_heartbeat.py (BE-AGT-001)
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CalendarBridgeClient = void 0;
exports.createCalendarBridgeClient = createCalendarBridgeClient;
class CalendarBridgeClient {
baseUrl;
config;
timeoutMs;
constructor(config) {
this.baseUrl = config.backendUrl.replace(/\/$/, ''); // strip trailing slash
this.config = {
timeoutMs: 5000,
...config,
};
this.timeoutMs = this.config.timeoutMs;
}
/**
* Fetch today's pending calendar slots for this agent.
*
* Heartbeat flow (§6.1):
* 1. Plugin sends heartbeat every minute
* 2. Backend returns slots where status is NotStarted or Deferred
* AND scheduled_at <= now
* 3. Plugin selects highest-priority slot (if any)
* 4. For remaining slots, plugin sets status = Deferred + priority += 1
*
* @returns CalendarHeartbeatResponse or null if the backend is unreachable
*/
async heartbeat() {
const url = `${this.baseUrl}/calendar/agent/heartbeat`;
const body = {
claw_identifier: this.config.clawIdentifier,
agent_id: this.config.agentId,
};
try {
const response = await this.fetchJson(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Agent-ID': this.config.agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
body: JSON.stringify(body),
});
return response;
}
catch (err) {
// Non-fatal: backend unreachable — return null, plugin continues
return null;
}
}
/**
* Update a real (materialized) slot's status after agent execution.
*
* Used by the plugin to report:
* - Slot attended (attended=true, started_at=now, status=Ongoing)
* - Slot finished (actual_duration set, status=Finished)
* - Slot deferred (status=Deferred, priority += 1)
* - Slot aborted (status=Aborted)
*
* @param slotId Real slot DB id
* @param update Status update payload
* @returns true on success, false on failure
*/
async updateSlot(slotId, update) {
const url = `${this.baseUrl}/calendar/slots/${slotId}/agent-update`;
return this.postBoolean(url, update);
}
/**
* Update a virtual (plan-generated) slot's status after agent execution.
*
* When updating a virtual slot, the backend first materializes it
* (creates a real TimeSlot row), then applies the update.
* The returned slot will have a real id on subsequent calls.
*
* @param virtualId Virtual slot id in format "plan-{plan_id}-{date}"
* @param update Status update payload
* @returns Updated CalendarSlotResponse on success, null on failure
*/
async updateVirtualSlot(virtualId, update) {
const url = `${this.baseUrl}/calendar/slots/virtual/${encodeURIComponent(virtualId)}/agent-update`;
try {
const response = await this.fetchJson(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Agent-ID': this.config.agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
body: JSON.stringify(update),
});
return response?.slot ?? null;
}
catch {
return null;
}
}
/**
* Report the agent's current runtime status to HarborForge.
*
* Used to push agent status transitions:
* idle → busy / on_call (when starting a slot)
* busy / on_call → idle (when finishing a slot)
* → exhausted (on rate-limit / billing error, with recovery_at)
* → offline (after 2 min with no heartbeat)
*
* @param status New agent status
* @param recoveryAt ISO timestamp for expected Exhausted recovery (optional)
* @param exhaustReason "rate_limit" | "billing" (required if status=exhausted)
*/
async reportAgentStatus(params) {
const url = `${this.baseUrl}/calendar/agent/status`;
const body = {
agent_id: this.config.agentId,
claw_identifier: this.config.clawIdentifier,
...params,
};
return this.postBoolean(url, body);
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
async fetchJson(url, init) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok)
return null;
return (await response.json());
}
catch {
clearTimeout(timeout);
return null;
}
}
async postBoolean(url, body) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Agent-ID': this.config.agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeout);
return response.ok;
}
catch {
clearTimeout(timeout);
return false;
}
}
}
exports.CalendarBridgeClient = CalendarBridgeClient;
// ---------------------------------------------------------------------------
// Utility: build CalendarBridgeConfig from plugin API context
// ---------------------------------------------------------------------------
const os_1 = require("os");
const live_config_1 = require("../core/live-config");
/**
* Build a CalendarBridgeClient from the OpenClaw plugin API context.
*
* @param api OpenClaw plugin API (register() receives this)
* @param fallbackUrl Fallback backend URL if not configured
* @param agentId $AGENT_ID from OpenClaw environment
*/
function createCalendarBridgeClient(api, fallbackUrl, agentId) {
const baseConfig = (0, live_config_1.getLivePluginConfig)(api, {
backendUrl: fallbackUrl,
identifier: (0, os_1.hostname)(),
});
const clawIdentifier = baseConfig.identifier || (0, os_1.hostname)();
return new CalendarBridgeClient({
backendUrl: baseConfig.backendUrl || fallbackUrl,
clawIdentifier,
agentId,
timeoutMs: 5000,
});
}
//# sourceMappingURL=calendar-bridge.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"calendar-bridge.js","sourceRoot":"","sources":["calendar-bridge.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;;;AA+NH,gEAkBC;AA5ND,MAAa,oBAAoB;IACvB,OAAO,CAAS;IAChB,MAAM,CAAiC;IACvC,SAAS,CAAS;IAE1B,YAAY,MAA4B;QACtC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,uBAAuB;QAC5E,IAAI,CAAC,MAAM,GAAG;YACZ,SAAS,EAAE,IAAI;YACf,GAAG,MAAM;SACV,CAAC;QACF,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC;IACzC,CAAC;IAED;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,SAAS;QACb,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,2BAA2B,CAAC;QACvD,MAAM,IAAI,GAA6B;YACrC,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;YAC3C,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,OAAO;SAC9B,CAAC;QAEF,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,SAAS,CAA4B,GAAG,EAAE;gBACpE,MAAM,EAAE,KAAK;gBACb,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,OAAO;oBACjC,mBAAmB,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;iBAChD;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;aAC3B,CAAC,CAAC;YACH,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,iEAAiE;YACjE,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,UAAU,CAAC,MAAc,EAAE,MAAuB;QACtD,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,mBAAmB,MAAM,eAAe,CAAC;QACpE,OAAO,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACvC,CAAC;IAED;;;;;;;;;;OAUG;IACH,KAAK,CAAC,iBAAiB,CACrB,SAAiB,EACjB,MAAuB;QAEvB,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,2BAA2B,kBAAkB,CAAC,SAAS,CAAC,eAAe,CAAC;QACnG,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,SAAS,CAAiC,GAAG,EAAE;gBACzE,MAAM,EAAE,OAAO;gBACf,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,OAAO;oBACjC,mBAAmB,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;iBAChD;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;aAC7B,CAAC,CAAC;YACH,OAAO,QAAQ,EAAE,IAAI,IAAI,IAAI,CAAC;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,iBAAiB,CAAC,MAIvB;QACC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,wBAAwB,CAAC;QACpD,MAAM,IAAI,GAAG;YACX,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,OAAO;YAC7B,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;YAC3C,GAAG,MAAM;SACV,CAAC;QACF,OAAO,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACrC,CAAC;IAED,4EAA4E;IAC5E,mBAAmB;IACnB,4EAA4E;IAEpE,KAAK,CAAC,SAAS,CACrB,GAAW,EACX,IAAiB;QAEjB,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QAErE,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;gBAChC,GAAG,IAAI;gBACP,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,YAAY,CAAC,OAAO,CAAC,CAAC;YAEtB,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,OAAO,IAAI,CAAC;YAC9B,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAM,CAAC;QACtC,CAAC;QAAC,MAAM,CAAC;YACP,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAEO,KAAK,CAAC,WAAW,CAAC,GAAW,EAAE,IAAa;QAClD,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QAErE,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;gBAChC,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,OAAO;oBACjC,mBAAmB,EAAE,IAAI,CAAC,MAAM,CAAC,cAAc;iBAChD;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;gBAC1B,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,QAAQ,CAAC,EAAE,CAAC;QACrB,CAAC;QAAC,MAAM,CAAC;YACP,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;CACF;AA/KD,oDA+KC;AAED,8EAA8E;AAC9E,8DAA8D;AAC9D,8EAA8E;AAE9E,2BAA8B;AAC9B,qDAAyF;AAazF;;;;;;GAMG;AACH,SAAgB,0BAA0B,CACxC,GAA4F,EAC5F,WAAmB,EACnB,OAAe;IAEf,MAAM,UAAU,GAAG,IAAA,iCAAmB,EAAC,GAAG,EAAE;QAC1C,UAAU,EAAE,WAAW;QACvB,UAAU,EAAE,IAAA,aAAQ,GAAE;KACK,CAA6B,CAAC;IAE3D,MAAM,cAAc,GAAG,UAAU,CAAC,UAAU,IAAI,IAAA,aAAQ,GAAE,CAAC;IAE3D,OAAO,IAAI,oBAAoB,CAAC;QAC9B,UAAU,EAAE,UAAU,CAAC,UAAU,IAAI,WAAW;QAChD,cAAc;QACd,OAAO;QACP,SAAS,EAAE,IAAI;KAChB,CAAC,CAAC;AACL,CAAC"}

View File

@@ -14,7 +14,7 @@
* Default backendUrl: "https://monitor.hangman-lab.top" * Default backendUrl: "https://monitor.hangman-lab.top"
* *
* Endpoints used: * Endpoints used:
* GET /calendar/agent/heartbeat — fetch pending slots * POST /calendar/agent/heartbeat — fetch pending slots
* PATCH /calendar/slots/{id}/agent-update — update real slot status * PATCH /calendar/slots/{id}/agent-update — update real slot status
* PATCH /calendar/slots/virtual/{vid}/agent-update — update virtual slot status * PATCH /calendar/slots/virtual/{vid}/agent-update — update virtual slot status
* *
@@ -29,7 +29,7 @@ import {
CalendarSlotResponse, CalendarSlotResponse,
SlotAgentUpdate, SlotAgentUpdate,
SlotStatus, SlotStatus,
} from './types'; } from './types.js';
export interface CalendarBridgeConfig { export interface CalendarBridgeConfig {
/** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */ /** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */
@@ -77,7 +77,7 @@ export class CalendarBridgeClient {
try { try {
const response = await this.fetchJson<CalendarHeartbeatResponse>(url, { const response = await this.fetchJson<CalendarHeartbeatResponse>(url, {
method: 'GET', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Agent-ID': this.config.agentId, 'X-Agent-ID': this.config.agentId,
@@ -107,7 +107,7 @@ export class CalendarBridgeClient {
*/ */
async updateSlot(slotId: number, update: SlotAgentUpdate): Promise<boolean> { async updateSlot(slotId: number, update: SlotAgentUpdate): Promise<boolean> {
const url = `${this.baseUrl}/calendar/slots/${slotId}/agent-update`; const url = `${this.baseUrl}/calendar/slots/${slotId}/agent-update`;
return this.postBoolean(url, update); return this.sendBoolean('PATCH', url, update);
} }
/** /**
@@ -166,7 +166,75 @@ export class CalendarBridgeClient {
claw_identifier: this.config.clawIdentifier, claw_identifier: this.config.clawIdentifier,
...params, ...params,
}; };
return this.postBoolean(url, body); return this.sendBoolean('POST', url, body);
}
/**
* Fetch the full day schedule for this agent.
*
* Unlike heartbeat() which only returns pending (NOT_STARTED/DEFERRED) slots,
* this returns ALL slots for the given date, enabling the plugin to maintain
* a complete local view of today's schedule.
*
* @param date Date string in YYYY-MM-DD format
* @returns Array of all slots for the day, or null if unreachable
*/
async getDaySchedule(date: string): Promise<CalendarSlotResponse[] | null> {
const url = `${this.baseUrl}/calendar/day?date=${encodeURIComponent(date)}`;
try {
const response = await this.fetchJson<{ slots: CalendarSlotResponse[] }>(url, {
method: 'GET',
headers: {
'X-Agent-ID': this.config.agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
});
return response?.slots ?? null;
} catch {
return null;
}
}
/**
* Sync today's schedules for all agents on this claw instance.
*
* Returns { agentId → slots[] } for all agents with matching claw_identifier.
* This is the primary data source for the multi-agent schedule cache.
*/
async syncSchedules(): Promise<{ schedules: Record<string, any[]>; date: string } | null> {
const url = `${this.baseUrl}/calendar/sync`;
try {
const response = await this.fetchJson<{ schedules: Record<string, any[]>; date: string }>(url, {
method: 'GET',
headers: {
'X-Claw-Identifier': this.config.clawIdentifier,
},
});
return response;
} catch {
return null;
}
}
/**
* Get a specific agent's status.
*
* @param agentId The agent ID to query
*/
async getAgentStatus(agentId: string): Promise<string | null> {
const url = `${this.baseUrl}/calendar/agent/status?agent_id=${encodeURIComponent(agentId)}`;
try {
const response = await this.fetchJson<{ status: string }>(url, {
method: 'GET',
headers: {
'X-Agent-ID': agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
});
return response?.status ?? null;
} catch {
return null;
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -195,13 +263,13 @@ export class CalendarBridgeClient {
} }
} }
private async postBoolean(url: string, body: unknown): Promise<boolean> { private async sendBoolean(method: 'POST' | 'PATCH', url: string, body: unknown): Promise<boolean> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs); const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Agent-ID': this.config.agentId, 'X-Agent-ID': this.config.agentId,
@@ -224,7 +292,7 @@ export class CalendarBridgeClient {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { hostname } from 'os'; import { hostname } from 'os';
import { getLivePluginConfig, type HarborForgeMonitorConfig } from '../core/live-config'; import { getPluginConfig } from '../core/config.js';
export interface CalendarPluginConfig { export interface CalendarPluginConfig {
/** Backend URL for calendar API (overrides monitor backendUrl) */ /** Backend URL for calendar API (overrides monitor backendUrl) */
@@ -249,10 +317,7 @@ export function createCalendarBridgeClient(
fallbackUrl: string, fallbackUrl: string,
agentId: string agentId: string
): CalendarBridgeClient { ): CalendarBridgeClient {
const baseConfig = getLivePluginConfig(api, { const baseConfig = getPluginConfig(api as any);
backendUrl: fallbackUrl,
identifier: hostname(),
} as HarborForgeMonitorConfig) as HarborForgeMonitorConfig;
const clawIdentifier = baseConfig.identifier || hostname(); const clawIdentifier = baseConfig.identifier || hostname();

View File

@@ -1,33 +0,0 @@
/**
* HarborForge Calendar — Plugin Module
*
* PLG-CAL-001: Calendar heartbeat request/response format definition.
* PLG-CAL-002: Plugin-side slot execution scheduler and agent wakeup.
*
* Exports:
* • Types for heartbeat request/response and slot update
* • CalendarBridgeClient — HTTP client for backend communication
* • createCalendarBridgeClient — factory from plugin API context
* • CalendarScheduler — manages periodic heartbeat and slot execution
* • createCalendarScheduler — factory for scheduler
* • AgentWakeContext — context passed to agent when waking
*
* Usage in plugin/index.ts:
* import { createCalendarBridgeClient, createCalendarScheduler } from './calendar';
*
* const agentId = process.env.AGENT_ID || 'unknown';
* const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId);
*
* const scheduler = createCalendarScheduler({
* bridge: calendar,
* getAgentStatus: async () => { ... },
* wakeAgent: async (context) => { ... },
* logger: api.logger,
* });
*
* scheduler.start();
*/
export * from './types';
export * from './calendar-bridge';
export * from './scheduler';
//# sourceMappingURL=index.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,cAAc,SAAS,CAAC;AACxB,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC"}

View File

@@ -1,49 +0,0 @@
"use strict";
/**
* HarborForge Calendar — Plugin Module
*
* PLG-CAL-001: Calendar heartbeat request/response format definition.
* PLG-CAL-002: Plugin-side slot execution scheduler and agent wakeup.
*
* Exports:
* • Types for heartbeat request/response and slot update
* • CalendarBridgeClient — HTTP client for backend communication
* • createCalendarBridgeClient — factory from plugin API context
* • CalendarScheduler — manages periodic heartbeat and slot execution
* • createCalendarScheduler — factory for scheduler
* • AgentWakeContext — context passed to agent when waking
*
* Usage in plugin/index.ts:
* import { createCalendarBridgeClient, createCalendarScheduler } from './calendar';
*
* const agentId = process.env.AGENT_ID || 'unknown';
* const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId);
*
* const scheduler = createCalendarScheduler({
* bridge: calendar,
* getAgentStatus: async () => { ... },
* wakeAgent: async (context) => { ... },
* logger: api.logger,
* });
*
* scheduler.start();
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./types"), exports);
__exportStar(require("./calendar-bridge"), exports);
__exportStar(require("./scheduler"), exports);
//# sourceMappingURL=index.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;;;;;;;;;;;;;;;;AAEH,0CAAwB;AACxB,oDAAkC;AAClC,8CAA4B"}

View File

@@ -13,7 +13,7 @@
* • AgentWakeContext — context passed to agent when waking * • AgentWakeContext — context passed to agent when waking
* *
* Usage in plugin/index.ts: * Usage in plugin/index.ts:
* import { createCalendarBridgeClient, createCalendarScheduler } from './calendar'; * import { createCalendarBridgeClient, createCalendarScheduler } from './calendar.js';
* *
* const agentId = process.env.AGENT_ID || 'unknown'; * const agentId = process.env.AGENT_ID || 'unknown';
* const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId); * const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId);
@@ -28,6 +28,7 @@
* scheduler.start(); * scheduler.start();
*/ */
export * from './types'; export * from './types.js';
export * from './calendar-bridge'; export * from './calendar-bridge.js';
export * from './scheduler'; export * from './scheduler.js';
export * from './schedule-cache.js';

View File

@@ -0,0 +1,101 @@
/**
* Multi-agent local schedule cache.
*
* Maintains today's schedule for all agents on this claw instance.
* Synced periodically from HF backend via /calendar/sync endpoint.
*/
export interface CachedSlot {
id: number | null;
virtual_id: string | null;
slot_type: string;
estimated_duration: number;
scheduled_at: string;
status: string;
priority: number;
event_type: string | null;
event_data: Record<string, unknown> | null;
[key: string]: unknown;
}
export class MultiAgentScheduleCache {
/** { agentId → slots[] } */
private schedules: Map<string, CachedSlot[]> = new Map();
private lastSyncAt: Date | null = null;
private cachedDate: string | null = null;
/**
* Replace cache with data from /calendar/sync response.
*/
sync(date: string, schedules: Record<string, CachedSlot[]>): void {
if (this.cachedDate !== date) {
this.schedules.clear();
}
this.cachedDate = date;
for (const [agentId, slots] of Object.entries(schedules)) {
this.schedules.set(agentId, slots);
}
this.lastSyncAt = new Date();
}
/**
* Get agents that have due (overdue or current) slots.
* Returns [agentId, dueSlots[]] pairs.
*/
getAgentsWithDueSlots(now: Date): Array<{ agentId: string; slots: CachedSlot[] }> {
const results: Array<{ agentId: string; slots: CachedSlot[] }> = [];
for (const [agentId, slots] of this.schedules) {
const due = slots.filter((s) => {
if (s.status !== 'not_started' && s.status !== 'deferred') return false;
const scheduledAt = this.parseScheduledTime(s.scheduled_at);
return scheduledAt !== null && scheduledAt <= now;
});
if (due.length > 0) {
// Sort by priority descending
due.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
results.push({ agentId, slots: due });
}
}
return results;
}
/**
* Get all agent IDs in the cache.
*/
getAgentIds(): string[] {
return Array.from(this.schedules.keys());
}
/**
* Get slots for a specific agent.
*/
getAgentSlots(agentId: string): CachedSlot[] {
return this.schedules.get(agentId) ?? [];
}
/**
* Get cache status for debugging.
*/
getStatus(): { agentCount: number; totalSlots: number; lastSyncAt: string | null; cachedDate: string | null } {
let totalSlots = 0;
for (const slots of this.schedules.values()) totalSlots += slots.length;
return {
agentCount: this.schedules.size,
totalSlots,
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
cachedDate: this.cachedDate,
};
}
private parseScheduledTime(scheduledAt: string): Date | null {
if (/^\d{2}:\d{2}(:\d{2})?$/.test(scheduledAt)) {
if (!this.cachedDate) return null;
return new Date(`${this.cachedDate}T${scheduledAt}Z`);
}
const d = new Date(scheduledAt);
return isNaN(d.getTime()) ? null : d;
}
}

View File

@@ -1,184 +0,0 @@
/**
* HarborForge Calendar Scheduler
*
* PLG-CAL-002: Plugin-side handling for pending slot execution.
*
* Responsibilities:
* - Run calendar heartbeat every minute
* - Detect when agent is Idle and slots are pending
* - Wake agent with task context
* - Handle slot status transitions (attended, ongoing, deferred)
* - Manage agent status transitions (idle → busy/on_call)
*
* Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism)
*/
import { CalendarBridgeClient } from './calendar-bridge';
import { CalendarSlotResponse, AgentStatusValue } from './types';
export interface CalendarSchedulerConfig {
/** Calendar bridge client for backend communication */
bridge: CalendarBridgeClient;
/** Function to get current agent status from backend */
getAgentStatus: () => Promise<AgentStatusValue | null>;
/** Function to wake/spawn agent with task context */
wakeAgent: (context: AgentWakeContext) => Promise<boolean>;
/** Logger instance */
logger: {
info: (...args: any[]) => void;
error: (...args: any[]) => void;
debug: (...args: any[]) => void;
warn: (...args: any[]) => void;
};
/** Heartbeat interval in milliseconds (default: 60000) */
heartbeatIntervalMs?: number;
/** Enable verbose debug logging */
debug?: boolean;
}
/**
* Context passed to agent when waking for slot execution.
* This is the payload the agent receives to understand what to do.
*/
export interface AgentWakeContext {
/** The slot to execute */
slot: CalendarSlotResponse;
/** Human-readable task description */
taskDescription: string;
/** Prompt/instructions for the agent */
prompt: string;
/** Whether this is a virtual slot (needs materialization) */
isVirtual: boolean;
}
/**
* Current execution state tracked by the scheduler.
*/
interface SchedulerState {
/** Whether scheduler is currently running */
isRunning: boolean;
/** Currently executing slot (null if idle) */
currentSlot: CalendarSlotResponse | null;
/** Last heartbeat timestamp */
lastHeartbeatAt: Date | null;
/** Interval handle for cleanup */
intervalHandle: ReturnType<typeof setInterval> | null;
/** Set of slot IDs that have been deferred in current session */
deferredSlotIds: Set<string>;
/** Whether agent is currently processing a slot */
isProcessing: boolean;
}
/**
* CalendarScheduler manages the periodic heartbeat and slot execution lifecycle.
*/
export declare class CalendarScheduler {
private config;
private state;
constructor(config: CalendarSchedulerConfig);
/**
* Start the calendar scheduler.
* Begins periodic heartbeat to check for pending slots.
*/
start(): void;
/**
* Stop the calendar scheduler.
* Cleans up intervals and resets state.
*/
stop(): void;
/**
* Execute a single heartbeat cycle.
* Fetches pending slots and handles execution logic.
*/
runHeartbeat(): Promise<void>;
/**
* Handle slots when agent is not idle.
* Defer all pending slots with priority boost.
*/
private handleNonIdleAgent;
/**
* Handle slots when agent is idle.
* Select highest priority slot and wake agent.
*/
private handleIdleAgent;
/**
* Execute a slot by waking the agent.
*/
private executeSlot;
/**
* Build the wake context for an agent based on slot details.
*/
private buildWakeContext;
/**
* Build prompt for job-type slots.
*/
private buildJobPrompt;
/**
* Build prompt for system event slots.
*/
private buildSystemPrompt;
/**
* Build prompt for entertainment slots.
*/
private buildEntertainmentPrompt;
/**
* Build generic prompt for slots without specific event data.
*/
private buildGenericPrompt;
/**
* Mark a slot as deferred with priority boost.
*/
private deferSlot;
/**
* Revert a slot to not_started status after failed execution attempt.
*/
private revertSlot;
/**
* Complete the current slot execution.
* Call this when the agent finishes the task.
*/
completeCurrentSlot(actualDurationMinutes: number): Promise<void>;
/**
* Abort the current slot execution.
* Call this when the agent cannot complete the task.
*/
abortCurrentSlot(reason?: string): Promise<void>;
/**
* Pause the current slot execution.
* Call this when the agent needs to temporarily pause.
*/
pauseCurrentSlot(): Promise<void>;
/**
* Resume a paused slot.
*/
resumeCurrentSlot(): Promise<void>;
/**
* Get a stable ID for a slot (real or virtual).
*/
private getSlotId;
/**
* Format a Date as ISO time string (HH:MM:SS).
*/
private formatTime;
/**
* Debug logging helper.
*/
private logDebug;
/**
* Get current scheduler state (for introspection).
*/
getState(): Readonly<SchedulerState>;
/**
* Check if scheduler is running.
*/
isRunning(): boolean;
/**
* Check if currently processing a slot.
*/
isProcessing(): boolean;
/**
* Get the current slot being executed (if any).
*/
getCurrentSlot(): CalendarSlotResponse | null;
}
/**
* Factory function to create a CalendarScheduler from plugin context.
*/
export declare function createCalendarScheduler(config: CalendarSchedulerConfig): CalendarScheduler;
export {};
//# sourceMappingURL=scheduler.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["scheduler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,oBAAoB,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,oBAAoB,EAEpB,gBAAgB,EAIjB,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,uBAAuB;IACtC,uDAAuD;IACvD,MAAM,EAAE,oBAAoB,CAAC;IAC7B,wDAAwD;IACxD,cAAc,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACvD,qDAAqD;IACrD,SAAS,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3D,sBAAsB;IACtB,MAAM,EAAE;QACN,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;QAC/B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;QAChC,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;QAChC,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;KAChC,CAAC;IACF,0DAA0D;IAC1D,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,mCAAmC;IACnC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,0BAA0B;IAC1B,IAAI,EAAE,oBAAoB,CAAC;IAC3B,sCAAsC;IACtC,eAAe,EAAE,MAAM,CAAC;IACxB,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,UAAU,cAAc;IACtB,6CAA6C;IAC7C,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,WAAW,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACzC,+BAA+B;IAC/B,eAAe,EAAE,IAAI,GAAG,IAAI,CAAC;IAC7B,kCAAkC;IAClC,cAAc,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,GAAG,IAAI,CAAC;IACtD,iEAAiE;IACjE,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC7B,mDAAmD;IACnD,YAAY,EAAE,OAAO,CAAC;CACvB;AAED;;GAEG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAoC;IAClD,OAAO,CAAC,KAAK,CAAiB;gBAElB,MAAM,EAAE,uBAAuB;IAgB3C;;;OAGG;IACH,KAAK,IAAI,IAAI;IAmBb;;;OAGG;IACH,IAAI,IAAI,IAAI;IAWZ;;;OAGG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAgCnC;;;OAGG;YACW,kBAAkB;IA0BhC;;;OAGG;YACW,eAAe;IAiC7B;;OAEG;YACW,WAAW;IAiEzB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAgCxB;;OAEG;IACH,OAAO,CAAC,cAAc;IAoBtB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAwCzB;;OAEG;IACH,OAAO,CAAC,wBAAwB;IAShC;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAU1B;;OAEG;YACW,SAAS;IAiBvB;;OAEG;YACW,UAAU;IAiBxB;;;OAGG;IACG,mBAAmB,CAAC,qBAAqB,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkCvE;;;OAGG;IACG,gBAAgB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiCtD;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAyBvC;;OAEG;IACG,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAyBxC;;OAEG;IACH,OAAO,CAAC,SAAS;IAIjB;;OAEG;IACH,OAAO,CAAC,UAAU;IAIlB;;OAEG;IACH,OAAO,CAAC,QAAQ;IAMhB;;OAEG;IACH,QAAQ,IAAI,QAAQ,CAAC,cAAc,CAAC;IAIpC;;OAEG;IACH,SAAS,IAAI,OAAO;IAIpB;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;OAEG;IACH,cAAc,IAAI,oBAAoB,GAAG,IAAI;CAG9C;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,iBAAiB,CAEnB"}

View File

@@ -1,526 +0,0 @@
"use strict";
/**
* HarborForge Calendar Scheduler
*
* PLG-CAL-002: Plugin-side handling for pending slot execution.
*
* Responsibilities:
* - Run calendar heartbeat every minute
* - Detect when agent is Idle and slots are pending
* - Wake agent with task context
* - Handle slot status transitions (attended, ongoing, deferred)
* - Manage agent status transitions (idle → busy/on_call)
*
* Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism)
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CalendarScheduler = void 0;
exports.createCalendarScheduler = createCalendarScheduler;
const types_1 = require("./types");
/**
* CalendarScheduler manages the periodic heartbeat and slot execution lifecycle.
*/
class CalendarScheduler {
config;
state;
constructor(config) {
this.config = {
heartbeatIntervalMs: 60000, // 1 minute default
debug: false,
...config,
};
this.state = {
isRunning: false,
currentSlot: null,
lastHeartbeatAt: null,
intervalHandle: null,
deferredSlotIds: new Set(),
isProcessing: false,
};
}
/**
* Start the calendar scheduler.
* Begins periodic heartbeat to check for pending slots.
*/
start() {
if (this.state.isRunning) {
this.config.logger.warn('Calendar scheduler already running');
return;
}
this.state.isRunning = true;
this.config.logger.info('Calendar scheduler started');
// Run initial heartbeat immediately
this.runHeartbeat();
// Schedule periodic heartbeats
this.state.intervalHandle = setInterval(() => this.runHeartbeat(), this.config.heartbeatIntervalMs);
}
/**
* Stop the calendar scheduler.
* Cleans up intervals and resets state.
*/
stop() {
this.state.isRunning = false;
if (this.state.intervalHandle) {
clearInterval(this.state.intervalHandle);
this.state.intervalHandle = null;
}
this.config.logger.info('Calendar scheduler stopped');
}
/**
* Execute a single heartbeat cycle.
* Fetches pending slots and handles execution logic.
*/
async runHeartbeat() {
if (!this.state.isRunning) {
return;
}
this.state.lastHeartbeatAt = new Date();
try {
// Fetch pending slots from backend
const response = await this.config.bridge.heartbeat();
if (!response) {
this.logDebug('Heartbeat: backend unreachable');
return;
}
this.logDebug(`Heartbeat: ${response.slots.length} slots pending, agent_status=${response.agent_status}`);
// If agent is not idle, defer all pending slots
if (response.agent_status !== 'idle') {
await this.handleNonIdleAgent(response.slots, response.agent_status);
return;
}
// Agent is idle - handle pending slots
await this.handleIdleAgent(response.slots);
}
catch (err) {
this.config.logger.error('Heartbeat error:', err);
}
}
/**
* Handle slots when agent is not idle.
* Defer all pending slots with priority boost.
*/
async handleNonIdleAgent(slots, agentStatus) {
if (slots.length === 0) {
return;
}
this.config.logger.info(`Agent not idle (status=${agentStatus}), deferring ${slots.length} slot(s)`);
for (const slot of slots) {
const slotId = this.getSlotId(slot);
// Skip if already deferred this session
if (this.state.deferredSlotIds.has(slotId)) {
continue;
}
// Mark slot as deferred with priority boost (+1)
await this.deferSlot(slot);
this.state.deferredSlotIds.add(slotId);
}
}
/**
* Handle slots when agent is idle.
* Select highest priority slot and wake agent.
*/
async handleIdleAgent(slots) {
if (slots.length === 0) {
return;
}
// Filter out already deferred slots in this session
const eligibleSlots = slots.filter(s => !this.state.deferredSlotIds.has(this.getSlotId(s)));
if (eligibleSlots.length === 0) {
this.logDebug('All pending slots have been deferred this session');
return;
}
// Select highest priority slot (backend already sorts by priority DESC)
const [selectedSlot, ...remainingSlots] = eligibleSlots;
this.config.logger.info(`Selected slot for execution: id=${this.getSlotId(selectedSlot)}, ` +
`type=${selectedSlot.slot_type}, priority=${selectedSlot.priority}`);
// Mark remaining slots as deferred
for (const slot of remainingSlots) {
await this.deferSlot(slot);
this.state.deferredSlotIds.add(this.getSlotId(slot));
}
// Wake agent to execute selected slot
await this.executeSlot(selectedSlot);
}
/**
* Execute a slot by waking the agent.
*/
async executeSlot(slot) {
if (this.state.isProcessing) {
this.config.logger.warn('Already processing a slot, deferring new slot');
await this.deferSlot(slot);
return;
}
this.state.isProcessing = true;
this.state.currentSlot = slot;
try {
// Mark slot as attended and ongoing before waking agent
const update = {
status: types_1.SlotStatus.ONGOING,
started_at: this.formatTime(new Date()),
};
let updateSuccess;
if (slot.id) {
updateSuccess = await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
const updated = await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
updateSuccess = updated !== null;
// Update slot reference if materialized
if (updated) {
this.state.currentSlot = updated;
}
}
else {
updateSuccess = false;
}
if (!updateSuccess) {
this.config.logger.error('Failed to update slot status before execution');
this.state.isProcessing = false;
this.state.currentSlot = null;
return;
}
// Report agent status change to backend
const newAgentStatus = slot.slot_type === 'on_call' ? 'on_call' : 'busy';
await this.config.bridge.reportAgentStatus({ status: newAgentStatus });
// Build wake context for agent
const wakeContext = this.buildWakeContext(slot);
// Wake the agent
const wakeSuccess = await this.config.wakeAgent(wakeContext);
if (!wakeSuccess) {
this.config.logger.error('Failed to wake agent for slot execution');
// Revert slot to not_started status
await this.revertSlot(slot);
await this.config.bridge.reportAgentStatus({ status: 'idle' });
}
// Note: isProcessing remains true until agent signals completion
// This is handled by external completion callback
}
catch (err) {
this.config.logger.error('Error executing slot:', err);
this.state.isProcessing = false;
this.state.currentSlot = null;
}
}
/**
* Build the wake context for an agent based on slot details.
*/
buildWakeContext(slot) {
const isVirtual = slot.virtual_id !== null;
const slotId = this.getSlotId(slot);
// Build task description based on event type
let taskDescription;
let prompt;
if (slot.event_type === 'job' && slot.event_data) {
const jobData = slot.event_data;
taskDescription = `${jobData.type} ${jobData.code}`;
prompt = this.buildJobPrompt(slot, jobData);
}
else if (slot.event_type === 'system_event' && slot.event_data) {
const sysData = slot.event_data;
taskDescription = `System Event: ${sysData.event}`;
prompt = this.buildSystemPrompt(slot, sysData);
}
else if (slot.event_type === 'entertainment') {
taskDescription = 'Entertainment slot';
prompt = this.buildEntertainmentPrompt(slot);
}
else {
taskDescription = `Generic ${slot.slot_type} slot`;
prompt = this.buildGenericPrompt(slot);
}
return {
slot,
taskDescription,
prompt,
isVirtual,
};
}
/**
* Build prompt for job-type slots.
*/
buildJobPrompt(slot, jobData) {
const duration = slot.estimated_duration;
const type = jobData.type;
const code = jobData.code;
return `You have a scheduled ${type} job to work on.
Task Code: ${code}
Estimated Duration: ${duration} minutes
Slot Type: ${slot.slot_type}
Priority: ${slot.priority}
Please focus on this task for the allocated time. When you finish or need to pause,
report your progress back to the calendar system.
Working sessions: ${jobData.working_sessions?.join(', ') || 'none recorded'}
Start working on ${code} now.`;
}
/**
* Build prompt for system event slots.
*/
buildSystemPrompt(slot, sysData) {
switch (sysData.event) {
case 'ScheduleToday':
return `System Event: Schedule Today
Please review today's calendar and schedule any pending tasks or planning activities.
Estimated time: ${slot.estimated_duration} minutes.
Check your calendar and plan the day's work.`;
case 'SummaryToday':
return `System Event: Daily Summary
Please provide a summary of today's activities and progress.
Estimated time: ${slot.estimated_duration} minutes.
Review what was accomplished and prepare end-of-day notes.`;
case 'ScheduledGatewayRestart':
return `System Event: Scheduled Gateway Restart
The OpenClaw gateway is scheduled to restart soon.
Please:
1. Persist any important state
2. Complete or gracefully pause current tasks
3. Prepare for restart
Time remaining: ${slot.estimated_duration} minutes.`;
default:
return `System Event: ${sysData.event}
A system event has been scheduled. Please handle accordingly.
Estimated time: ${slot.estimated_duration} minutes.`;
}
}
/**
* Build prompt for entertainment slots.
*/
buildEntertainmentPrompt(slot) {
return `Scheduled Entertainment Break
Duration: ${slot.estimated_duration} minutes
Take a break and enjoy some leisure time. This slot is reserved for non-work activities
to help maintain work-life balance.`;
}
/**
* Build generic prompt for slots without specific event data.
*/
buildGenericPrompt(slot) {
return `Scheduled Calendar Slot
Type: ${slot.slot_type}
Duration: ${slot.estimated_duration} minutes
Priority: ${slot.priority}
Please use this time for the scheduled activity.`;
}
/**
* Mark a slot as deferred with priority boost.
*/
async deferSlot(slot) {
const update = {
status: types_1.SlotStatus.DEFERRED,
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
this.logDebug(`Deferred slot: ${this.getSlotId(slot)}`);
}
catch (err) {
this.config.logger.error('Failed to defer slot:', err);
}
}
/**
* Revert a slot to not_started status after failed execution attempt.
*/
async revertSlot(slot) {
const update = {
status: types_1.SlotStatus.NOT_STARTED,
started_at: undefined,
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
}
catch (err) {
this.config.logger.error('Failed to revert slot:', err);
}
}
/**
* Complete the current slot execution.
* Call this when the agent finishes the task.
*/
async completeCurrentSlot(actualDurationMinutes) {
if (!this.state.currentSlot) {
this.config.logger.warn('No current slot to complete');
return;
}
const slot = this.state.currentSlot;
const update = {
status: types_1.SlotStatus.FINISHED,
actual_duration: actualDurationMinutes,
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
// Report agent back to idle
await this.config.bridge.reportAgentStatus({ status: 'idle' });
this.config.logger.info(`Completed slot ${this.getSlotId(slot)}, actual_duration=${actualDurationMinutes}min`);
}
catch (err) {
this.config.logger.error('Failed to complete slot:', err);
}
finally {
this.state.isProcessing = false;
this.state.currentSlot = null;
}
}
/**
* Abort the current slot execution.
* Call this when the agent cannot complete the task.
*/
async abortCurrentSlot(reason) {
if (!this.state.currentSlot) {
this.config.logger.warn('No current slot to abort');
return;
}
const slot = this.state.currentSlot;
const update = {
status: types_1.SlotStatus.ABORTED,
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
// Report agent back to idle
await this.config.bridge.reportAgentStatus({ status: 'idle' });
this.config.logger.info(`Aborted slot ${this.getSlotId(slot)}${reason ? `: ${reason}` : ''}`);
}
catch (err) {
this.config.logger.error('Failed to abort slot:', err);
}
finally {
this.state.isProcessing = false;
this.state.currentSlot = null;
}
}
/**
* Pause the current slot execution.
* Call this when the agent needs to temporarily pause.
*/
async pauseCurrentSlot() {
if (!this.state.currentSlot) {
this.config.logger.warn('No current slot to pause');
return;
}
const slot = this.state.currentSlot;
const update = {
status: types_1.SlotStatus.PAUSED,
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
this.config.logger.info(`Paused slot ${this.getSlotId(slot)}`);
}
catch (err) {
this.config.logger.error('Failed to pause slot:', err);
}
}
/**
* Resume a paused slot.
*/
async resumeCurrentSlot() {
if (!this.state.currentSlot) {
this.config.logger.warn('No current slot to resume');
return;
}
const slot = this.state.currentSlot;
const update = {
status: types_1.SlotStatus.ONGOING,
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
}
else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
this.config.logger.info(`Resumed slot ${this.getSlotId(slot)}`);
}
catch (err) {
this.config.logger.error('Failed to resume slot:', err);
}
}
/**
* Get a stable ID for a slot (real or virtual).
*/
getSlotId(slot) {
return slot.id?.toString() || slot.virtual_id || 'unknown';
}
/**
* Format a Date as ISO time string (HH:MM:SS).
*/
formatTime(date) {
return date.toTimeString().split(' ')[0];
}
/**
* Debug logging helper.
*/
logDebug(message) {
if (this.config.debug) {
this.config.logger.debug(`[CalendarScheduler] ${message}`);
}
}
/**
* Get current scheduler state (for introspection).
*/
getState() {
return { ...this.state };
}
/**
* Check if scheduler is running.
*/
isRunning() {
return this.state.isRunning;
}
/**
* Check if currently processing a slot.
*/
isProcessing() {
return this.state.isProcessing;
}
/**
* Get the current slot being executed (if any).
*/
getCurrentSlot() {
return this.state.currentSlot;
}
}
exports.CalendarScheduler = CalendarScheduler;
/**
* Factory function to create a CalendarScheduler from plugin context.
*/
function createCalendarScheduler(config) {
return new CalendarScheduler(config);
}
//# sourceMappingURL=scheduler.js.map

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@
* HarborForge Calendar Scheduler * HarborForge Calendar Scheduler
* *
* PLG-CAL-002: Plugin-side handling for pending slot execution. * PLG-CAL-002: Plugin-side handling for pending slot execution.
* PLG-CAL-004: ScheduledGatewayRestart event handling with state persistence.
* *
* Responsibilities: * Responsibilities:
* - Run calendar heartbeat every minute * - Run calendar heartbeat every minute
@@ -9,13 +10,15 @@
* - Wake agent with task context * - Wake agent with task context
* - Handle slot status transitions (attended, ongoing, deferred) * - Handle slot status transitions (attended, ongoing, deferred)
* - Manage agent status transitions (idle → busy/on_call) * - Manage agent status transitions (idle → busy/on_call)
* - Persist state on ScheduledGatewayRestart and restore on startup
* - Send final heartbeat before graceful shutdown
* *
* Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism) * Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism)
*/ */
import { import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
CalendarBridgeClient, import { join, dirname } from 'path';
} from './calendar-bridge'; import { CalendarBridgeClient } from './calendar-bridge.js';
import { import {
CalendarSlotResponse, CalendarSlotResponse,
SlotStatus, SlotStatus,
@@ -23,7 +26,7 @@ import {
SlotAgentUpdate, SlotAgentUpdate,
CalendarEventDataJob, CalendarEventDataJob,
CalendarEventDataSystemEvent, CalendarEventDataSystemEvent,
} from './types'; } from './types.js';
export interface CalendarSchedulerConfig { export interface CalendarSchedulerConfig {
/** Calendar bridge client for backend communication */ /** Calendar bridge client for backend communication */
@@ -43,6 +46,8 @@ export interface CalendarSchedulerConfig {
heartbeatIntervalMs?: number; heartbeatIntervalMs?: number;
/** Enable verbose debug logging */ /** Enable verbose debug logging */
debug?: boolean; debug?: boolean;
/** Directory for state persistence (default: plugin data dir) */
stateDir?: string;
} }
/** /**
@@ -60,6 +65,26 @@ export interface AgentWakeContext {
isVirtual: boolean; isVirtual: boolean;
} }
/**
* Persisted state structure for recovery after restart.
*/
interface PersistedState {
/** Version for migration compatibility */
version: number;
/** When the state was persisted */
persistedAt: string;
/** Reason for persistence (e.g., 'ScheduledGatewayRestart') */
reason: string;
/** The slot that was being executed when persisted */
currentSlot: CalendarSlotResponse | null;
/** Deferred slot IDs at persistence time */
deferredSlotIds: string[];
/** Whether a slot was in progress */
isProcessing: boolean;
/** Agent status at persistence time */
agentStatus: AgentStatusValue | null;
}
/** /**
* Current execution state tracked by the scheduler. * Current execution state tracked by the scheduler.
*/ */
@@ -76,21 +101,33 @@ interface SchedulerState {
deferredSlotIds: Set<string>; deferredSlotIds: Set<string>;
/** Whether agent is currently processing a slot */ /** Whether agent is currently processing a slot */
isProcessing: boolean; isProcessing: boolean;
/** Whether a gateway restart is scheduled/pending */
isRestartPending: boolean;
} }
/** State file name */
const STATE_FILENAME = 'calendar-scheduler-state.json';
/** State file version for migration compatibility */
const STATE_VERSION = 1;
/** /**
* CalendarScheduler manages the periodic heartbeat and slot execution lifecycle. * CalendarScheduler manages the periodic heartbeat and slot execution lifecycle.
*/ */
export class CalendarScheduler { export class CalendarScheduler {
private config: Required<CalendarSchedulerConfig>; private config: Required<CalendarSchedulerConfig>;
private state: SchedulerState; private state: SchedulerState;
private stateFilePath: string;
constructor(config: CalendarSchedulerConfig) { constructor(config: CalendarSchedulerConfig) {
this.config = { this.config = {
heartbeatIntervalMs: 60000, // 1 minute default heartbeatIntervalMs: 60000, // 1 minute default
debug: false, debug: false,
stateDir: this.getDefaultStateDir(),
...config, ...config,
}; };
this.stateFilePath = join(this.config.stateDir, STATE_FILENAME);
this.state = { this.state = {
isRunning: false, isRunning: false,
currentSlot: null, currentSlot: null,
@@ -98,7 +135,182 @@ export class CalendarScheduler {
intervalHandle: null, intervalHandle: null,
deferredSlotIds: new Set(), deferredSlotIds: new Set(),
isProcessing: false, isProcessing: false,
isRestartPending: false,
}; };
// Attempt to restore state from previous persistence
this.restoreState();
}
/**
* Get default state directory (plugin data directory or temp fallback).
*/
private getDefaultStateDir(): string {
// Try to use the plugin's directory or a standard data location
const candidates = [
process.env.OPENCLAW_PLUGIN_DATA_DIR,
process.env.HARBORFORGE_PLUGIN_DIR,
join(process.cwd(), '.harborforge'),
join(process.cwd(), 'data'),
'/tmp/harborforge',
];
for (const dir of candidates) {
if (dir) {
try {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Test write access
const testFile = join(dir, '.write-test');
writeFileSync(testFile, '', { flag: 'w' });
return dir;
} catch {
continue;
}
}
}
// Fallback to current working directory
return process.cwd();
}
/**
* Persist current state to disk for recovery after restart.
*/
private persistState(reason: string): void {
try {
const persistedState: PersistedState = {
version: STATE_VERSION,
persistedAt: new Date().toISOString(),
reason,
currentSlot: this.state.currentSlot,
deferredSlotIds: Array.from(this.state.deferredSlotIds),
isProcessing: this.state.isProcessing,
agentStatus: null, // Will be determined at restore time
};
writeFileSync(this.stateFilePath, JSON.stringify(persistedState, null, 2));
this.config.logger.info(`[PLG-CAL-004] State persisted to ${this.stateFilePath} (reason: ${reason})`);
} catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to persist state:', err);
}
}
/**
* Restore state from disk if available.
*/
private restoreState(): void {
try {
if (!existsSync(this.stateFilePath)) {
return;
}
const data = readFileSync(this.stateFilePath, 'utf-8');
const persisted: PersistedState = JSON.parse(data);
// Validate version
if (persisted.version !== STATE_VERSION) {
this.config.logger.warn(`[PLG-CAL-004] State version mismatch: ${persisted.version} vs ${STATE_VERSION}`);
this.clearPersistedState();
return;
}
// Restore deferred slot IDs
if (persisted.deferredSlotIds && persisted.deferredSlotIds.length > 0) {
this.state.deferredSlotIds = new Set(persisted.deferredSlotIds);
this.config.logger.info(`[PLG-CAL-004] Restored ${persisted.deferredSlotIds.length} deferred slot(s)`);
}
// If there was a slot in progress, mark it for replanning
if (persisted.isProcessing && persisted.currentSlot) {
this.config.logger.warn(
`[PLG-CAL-004] Previous session had in-progress slot: ${this.getSlotId(persisted.currentSlot)}`
);
// The slot will be picked up by the next heartbeat and can be resumed or deferred
}
this.config.logger.info(`[PLG-CAL-004] State restored from ${persisted.persistedAt} (reason: ${persisted.reason})`);
// Clear the persisted state after successful restore
this.clearPersistedState();
} catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to restore state:', err);
}
}
/**
* Clear persisted state file after successful restore.
*/
private clearPersistedState(): void {
try {
if (existsSync(this.stateFilePath)) {
// In a real implementation, we might want to archive instead of delete
// For now, we'll just clear the content to mark as processed
writeFileSync(this.stateFilePath, JSON.stringify({ restored: true, at: new Date().toISOString() }));
}
} catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to clear persisted state:', err);
}
}
/**
* Send a final heartbeat to the backend before shutdown.
*/
private async sendFinalHeartbeat(reason: string): Promise<void> {
try {
this.config.logger.info(`[PLG-CAL-004] Sending final heartbeat (reason: ${reason})`);
// Send agent status update indicating we're going offline
await this.config.bridge.reportAgentStatus({ status: 'offline' });
this.config.logger.info('[PLG-CAL-004] Final heartbeat sent successfully');
} catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to send final heartbeat:', err);
}
}
/**
* Handle ScheduledGatewayRestart event.
* PLG-CAL-004: Persist state, send final heartbeat, pause scheduled tasks.
*/
private async handleScheduledGatewayRestart(slot: CalendarSlotResponse): Promise<void> {
this.config.logger.info('[PLG-CAL-004] Handling ScheduledGatewayRestart event');
// 1. Mark restart as pending to prevent new slot processing
this.state.isRestartPending = true;
// 2. Persist current state
this.persistState('ScheduledGatewayRestart');
// 3. If there's a current slot, pause it gracefully
if (this.state.isProcessing && this.state.currentSlot) {
this.config.logger.info('[PLG-CAL-004] Pausing current slot before restart');
await this.pauseCurrentSlot();
}
// 4. Send final heartbeat
await this.sendFinalHeartbeat('ScheduledGatewayRestart');
// 5. Stop the scheduler (pause scheduled tasks)
this.config.logger.info('[PLG-CAL-004] Stopping scheduler due to gateway restart');
this.stop();
// 6. Mark the slot as finished (since we've handled the restart)
const update: SlotAgentUpdate = {
status: SlotStatus.FINISHED,
actual_duration: 0, // Restart preparation doesn't take time
};
try {
if (slot.id) {
await this.config.bridge.updateSlot(slot.id, update);
} else if (slot.virtual_id) {
await this.config.bridge.updateVirtualSlot(slot.virtual_id, update);
}
} catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to mark restart slot as finished:', err);
}
} }
/** /**
@@ -112,6 +324,7 @@ export class CalendarScheduler {
} }
this.state.isRunning = true; this.state.isRunning = true;
this.state.isRestartPending = false;
this.config.logger.info('Calendar scheduler started'); this.config.logger.info('Calendar scheduler started');
// Run initial heartbeat immediately // Run initial heartbeat immediately
@@ -148,6 +361,12 @@ export class CalendarScheduler {
return; return;
} }
// Skip heartbeat if restart is pending
if (this.state.isRestartPending) {
this.logDebug('Heartbeat skipped: gateway restart pending');
return;
}
this.state.lastHeartbeatAt = new Date(); this.state.lastHeartbeatAt = new Date();
try { try {
@@ -159,7 +378,9 @@ export class CalendarScheduler {
return; return;
} }
this.logDebug(`Heartbeat: ${response.slots.length} slots pending, agent_status=${response.agent_status}`); this.logDebug(
`Heartbeat: ${response.slots.length} slots pending, agent_status=${response.agent_status}`
);
// If agent is not idle, defer all pending slots // If agent is not idle, defer all pending slots
if (response.agent_status !== 'idle') { if (response.agent_status !== 'idle') {
@@ -167,9 +388,17 @@ export class CalendarScheduler {
return; return;
} }
// Agent is idle again - previously deferred slots should become eligible
// for selection in the next planning pass.
if (this.state.deferredSlotIds.size > 0) {
this.logDebug(
`Agent returned to idle; clearing ${this.state.deferredSlotIds.size} deferred slot marker(s) for replanning`
);
this.state.deferredSlotIds.clear();
}
// Agent is idle - handle pending slots // Agent is idle - handle pending slots
await this.handleIdleAgent(response.slots); await this.handleIdleAgent(response.slots);
} catch (err) { } catch (err) {
this.config.logger.error('Heartbeat error:', err); this.config.logger.error('Heartbeat error:', err);
} }
@@ -216,7 +445,7 @@ export class CalendarScheduler {
// Filter out already deferred slots in this session // Filter out already deferred slots in this session
const eligibleSlots = slots.filter( const eligibleSlots = slots.filter(
s => !this.state.deferredSlotIds.has(this.getSlotId(s)) (s) => !this.state.deferredSlotIds.has(this.getSlotId(s))
); );
if (eligibleSlots.length === 0) { if (eligibleSlots.length === 0) {
@@ -229,7 +458,7 @@ export class CalendarScheduler {
this.config.logger.info( this.config.logger.info(
`Selected slot for execution: id=${this.getSlotId(selectedSlot)}, ` + `Selected slot for execution: id=${this.getSlotId(selectedSlot)}, ` +
`type=${selectedSlot.slot_type}, priority=${selectedSlot.priority}` `type=${selectedSlot.slot_type}, priority=${selectedSlot.priority}`
); );
// Mark remaining slots as deferred // Mark remaining slots as deferred
@@ -238,10 +467,27 @@ export class CalendarScheduler {
this.state.deferredSlotIds.add(this.getSlotId(slot)); this.state.deferredSlotIds.add(this.getSlotId(slot));
} }
// Check if this is a ScheduledGatewayRestart event
if (this.isScheduledGatewayRestart(selectedSlot)) {
await this.handleScheduledGatewayRestart(selectedSlot);
return;
}
// Wake agent to execute selected slot // Wake agent to execute selected slot
await this.executeSlot(selectedSlot); await this.executeSlot(selectedSlot);
} }
/**
* Check if a slot is a ScheduledGatewayRestart system event.
*/
private isScheduledGatewayRestart(slot: CalendarSlotResponse): boolean {
if (slot.event_type !== 'system_event' || !slot.event_data) {
return false;
}
const sysData = slot.event_data as CalendarEventDataSystemEvent;
return sysData.event === 'ScheduledGatewayRestart';
}
/** /**
* Execute a slot by waking the agent. * Execute a slot by waking the agent.
*/ */
@@ -298,11 +544,14 @@ export class CalendarScheduler {
// Revert slot to not_started status // Revert slot to not_started status
await this.revertSlot(slot); await this.revertSlot(slot);
await this.config.bridge.reportAgentStatus({ status: 'idle' }); await this.config.bridge.reportAgentStatus({ status: 'idle' });
this.state.isProcessing = false;
this.state.currentSlot = null;
await this.triggerReplan('wake failure');
return;
} }
// Note: isProcessing remains true until agent signals completion // Note: isProcessing remains true until agent signals completion
// This is handled by external completion callback // This is handled by external completion callback
} catch (err) { } catch (err) {
this.config.logger.error('Error executing slot:', err); this.config.logger.error('Error executing slot:', err);
this.state.isProcessing = false; this.state.isProcessing = false;
@@ -348,7 +597,10 @@ export class CalendarScheduler {
/** /**
* Build prompt for job-type slots. * Build prompt for job-type slots.
*/ */
private buildJobPrompt(slot: CalendarSlotResponse, jobData: CalendarEventDataJob): string { private buildJobPrompt(
slot: CalendarSlotResponse,
jobData: CalendarEventDataJob
): string {
const duration = slot.estimated_duration; const duration = slot.estimated_duration;
const type = jobData.type; const type = jobData.type;
const code = jobData.code; const code = jobData.code;
@@ -505,12 +757,12 @@ Please use this time for the scheduled activity.`;
this.config.logger.info( this.config.logger.info(
`Completed slot ${this.getSlotId(slot)}, actual_duration=${actualDurationMinutes}min` `Completed slot ${this.getSlotId(slot)}, actual_duration=${actualDurationMinutes}min`
); );
} catch (err) { } catch (err) {
this.config.logger.error('Failed to complete slot:', err); this.config.logger.error('Failed to complete slot:', err);
} finally { } finally {
this.state.isProcessing = false; this.state.isProcessing = false;
this.state.currentSlot = null; this.state.currentSlot = null;
await this.triggerReplan('slot completion');
} }
} }
@@ -542,12 +794,12 @@ Please use this time for the scheduled activity.`;
this.config.logger.info( this.config.logger.info(
`Aborted slot ${this.getSlotId(slot)}${reason ? `: ${reason}` : ''}` `Aborted slot ${this.getSlotId(slot)}${reason ? `: ${reason}` : ''}`
); );
} catch (err) { } catch (err) {
this.config.logger.error('Failed to abort slot:', err); this.config.logger.error('Failed to abort slot:', err);
} finally { } finally {
this.state.isProcessing = false; this.state.isProcessing = false;
this.state.currentSlot = null; this.state.currentSlot = null;
await this.triggerReplan('slot abort');
} }
} }
@@ -574,7 +826,6 @@ Please use this time for the scheduled activity.`;
} }
this.config.logger.info(`Paused slot ${this.getSlotId(slot)}`); this.config.logger.info(`Paused slot ${this.getSlotId(slot)}`);
} catch (err) { } catch (err) {
this.config.logger.error('Failed to pause slot:', err); this.config.logger.error('Failed to pause slot:', err);
} }
@@ -602,12 +853,30 @@ Please use this time for the scheduled activity.`;
} }
this.config.logger.info(`Resumed slot ${this.getSlotId(slot)}`); this.config.logger.info(`Resumed slot ${this.getSlotId(slot)}`);
} catch (err) { } catch (err) {
this.config.logger.error('Failed to resume slot:', err); this.config.logger.error('Failed to resume slot:', err);
} }
} }
/**
* Trigger an immediate replanning pass after the current slot lifecycle ends.
* This lets previously deferred/not-started slots compete again as soon as
* the agent becomes idle.
*/
private async triggerReplan(reason: string): Promise<void> {
if (!this.state.isRunning) {
return;
}
this.logDebug(`Triggering immediate replanning after ${reason}`);
try {
await this.runHeartbeat();
} catch (err) {
this.config.logger.error(`Failed to trigger replanning after ${reason}:`, err);
}
}
/** /**
* Get a stable ID for a slot (real or virtual). * Get a stable ID for a slot (real or virtual).
*/ */
@@ -658,6 +927,20 @@ Please use this time for the scheduled activity.`;
getCurrentSlot(): CalendarSlotResponse | null { getCurrentSlot(): CalendarSlotResponse | null {
return this.state.currentSlot; return this.state.currentSlot;
} }
/**
* Check if a gateway restart is pending.
*/
isRestartPending(): boolean {
return this.state.isRestartPending;
}
/**
* Get the path to the state file.
*/
getStateFilePath(): string {
return this.stateFilePath;
}
} }
/** /**

View File

@@ -1,171 +0,0 @@
/**
* HarborForge Calendar — Plugin-side type definitions
*
* PLG-CAL-001: Define the Calendar heartbeat request/response format
* between the OpenClaw plugin and HarborForge backend.
*
* Request flow (plugin → backend):
* POST /calendar/agent/heartbeat
* Headers:
* X-Agent-ID: <agent_id> — OpenClaw $AGENT_ID of the calling agent
* X-Claw-Identifier: <identifier> — HarborForge server identifier
* Body (JSON):
* { "claw_identifier": "...", "agent_id": "..." }
*
* Response flow (backend → plugin):
* Returns list of TimeSlots pending execution for today.
* The plugin uses slot.id / slot.virtual_id to update slot status
* via subsequent API calls.
*
* References:
* • NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism)
* • HarborForge.Backend/app/models/calendar.py — TimeSlot DB model
* • HarborForge.Backend/app/schemas/calendar.py — TimeSlot schemas
* • HarborForge.Backend/app/services/agent_heartbeat.py — BE-AGT-001
*/
/** Slot type — mirrors backend SlotType enum */
export declare enum SlotType {
WORK = "work",
ON_CALL = "on_call",
ENTERTAINMENT = "entertainment",
SYSTEM = "system"
}
/** Slot lifecycle status — mirrors backend SlotStatus enum */
export declare enum SlotStatus {
NOT_STARTED = "not_started",
ONGOING = "ongoing",
DEFERRED = "deferred",
SKIPPED = "skipped",
PAUSED = "paused",
FINISHED = "finished",
ABORTED = "aborted"
}
/** High-level event category — mirrors backend EventType enum */
export declare enum EventType {
JOB = "job",
ENTERTAINMENT = "entertainment",
SYSTEM_EVENT = "system_event"
}
/**
* Calendar heartbeat request body sent by the plugin to HarborForge backend.
*
* How claw_identifier is determined:
* 1. Read from plugin config: `config.backendUrl` is the base URL.
* 2. If not set, fall back to `os.hostname()` (plugin machine hostname).
*
* How agent_id is determined:
* - Read from OpenClaw environment variable: `process.env.AGENT_ID`
* - This is set by OpenClaw at agent startup and uniquely identifies
* the running agent instance within a single OpenClaw gateway.
*/
export interface CalendarHeartbeatRequest {
/** HarborForge server/claw identifier (matches MonitoredServer.identifier) */
claw_identifier: string;
/** OpenClaw agent ID ($AGENT_ID) for this agent session */
agent_id: string;
}
/**
* A single calendar slot returned in the heartbeat response.
*
* For **real** (materialized) slots: `id` is set, `virtual_id` is null.
* For **virtual** (plan-generated) slots: `id` is null, `virtual_id`
* is the `plan-{plan_id}-{date}` identifier.
*
* Key fields the plugin uses:
* - `id` / `virtual_id` — to update slot status after execution
* - `event_type` — to determine what action to take
* - `event_data` — job details / system event type
* - `slot_type` — work vs on_call (affects agent status transition)
* - `scheduled_at` — planned start time (HH:MM:SS)
* - `estimated_duration` — expected minutes (for time-tracking)
* - `priority` — for multi-slot competition logic
* - `status` — current status (NotStarted / Deferred)
*/
export interface CalendarSlotResponse {
/** Real slot DB id. Null for virtual slots. */
id: number | null;
/** Virtual slot id (plan-{plan_id}-{date}). Null for real slots. */
virtual_id: string | null;
/** Owner user id */
user_id: number;
/** Calendar date */
date: string;
/** Slot type */
slot_type: SlotType;
/** Estimated duration in minutes (1-50) */
estimated_duration: number;
/** Planned start time (ISO time string: "HH:MM:SS") */
scheduled_at: string;
/** Actual start time, set when slot begins (null until started) */
started_at: string | null;
/** Whether the slot has been attended */
attended: boolean;
/** Actual duration in minutes (set when slot finishes) */
actual_duration: number | null;
/** Event category */
event_type: EventType | null;
/** Event details JSON — structure depends on event_type (see below) */
event_data: CalendarEventData | null;
/** Priority 0-99, higher = more urgent */
priority: number;
/** Current lifecycle status */
status: SlotStatus;
/** Source plan id if materialized from a SchedulePlan; null otherwise */
plan_id: number | null;
}
/**
* Event data stored inside CalendarSlotResponse.event_data.
* The shape depends on event_type.
*
* When event_type == "job":
* { "type": "Task|Support|Meeting|Essential", "code": "TASK-42", "working_sessions": ["..."] }
*
* When event_type == "system_event":
* { "event": "ScheduleToday|SummaryToday|ScheduledGatewayRestart" }
*
* When event_type == "entertainment":
* { /* TBD /\ }
*/
export interface CalendarEventDataJob {
type: 'Task' | 'Support' | 'Meeting' | 'Essential';
code: string;
working_sessions?: string[];
}
export interface CalendarEventDataSystemEvent {
event: 'ScheduleToday' | 'SummaryToday' | 'ScheduledGatewayRestart';
}
export type CalendarEventData = CalendarEventDataJob | CalendarEventDataSystemEvent | Record<string, any>;
/**
* Full heartbeat response returned by GET /calendar/agent/heartbeat
*
* Fields:
* slots — list of pending TimeSlots for today (sorted by priority desc)
* agent_status — current agent status from the backend's perspective
* (idle | on_call | busy | exhausted | offline)
*/
export interface CalendarHeartbeatResponse {
/** Pending slots for today — sorted by priority descending */
slots: CalendarSlotResponse[];
/** Current agent status in HarborForge */
agent_status: AgentStatusValue;
/** Human-readable message (optional) */
message?: string;
}
/** Agent status values — mirrors backend AgentStatus enum */
export type AgentStatusValue = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
/**
* Request body for updating a real slot's status after agent execution.
* Called by the plugin after attending / finishing / deferring a slot.
*
* Endpoint: PATCH /calendar/slots/{slot_id}/agent-update
* (Plugin-facing variant that bypasses some user-level guards)
*/
export interface SlotAgentUpdate {
/** New status to set */
status: SlotStatus;
/** Actual start time (ISO time string HH:MM:SS), required when attending */
started_at?: string;
/** Actual duration in minutes, set when finishing */
actual_duration?: number;
}
//# sourceMappingURL=types.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAMH,gDAAgD;AAChD,oBAAY,QAAQ;IAClB,IAAI,SAAS;IACb,OAAO,YAAY;IACnB,aAAa,kBAAkB;IAC/B,MAAM,WAAW;CAClB;AAED,8DAA8D;AAC9D,oBAAY,UAAU;IACpB,WAAW,gBAAgB;IAC3B,OAAO,YAAY;IACnB,QAAQ,aAAa;IACrB,OAAO,YAAY;IACnB,MAAM,WAAW;IACjB,QAAQ,aAAa;IACrB,OAAO,YAAY;CACpB;AAED,iEAAiE;AACjE,oBAAY,SAAS;IACnB,GAAG,QAAQ;IACX,aAAa,kBAAkB;IAC/B,YAAY,iBAAiB;CAC9B;AAMD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,wBAAwB;IACvC,8EAA8E;IAC9E,eAAe,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;CAClB;AAMD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,oBAAoB;IACnC,+CAA+C;IAC/C,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,oEAAoE;IACpE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,oBAAoB;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB;IAChB,SAAS,EAAE,QAAQ,CAAC;IACpB,2CAA2C;IAC3C,kBAAkB,EAAE,MAAM,CAAC;IAC3B,uDAAuD;IACvD,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,yCAAyC;IACzC,QAAQ,EAAE,OAAO,CAAC;IAClB,0DAA0D;IAC1D,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,qBAAqB;IACrB,UAAU,EAAE,SAAS,GAAG,IAAI,CAAC;IAC7B,uEAAuE;IACvE,UAAU,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACrC,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,+BAA+B;IAC/B,MAAM,EAAE,UAAU,CAAC;IACnB,yEAAyE;IACzE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,eAAe,GAAG,cAAc,GAAG,yBAAyB,CAAC;CACrE;AAGD,MAAM,MAAM,iBAAiB,GAAG,oBAAoB,GAAG,4BAA4B,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAE1G;;;;;;;GAOG;AACH,MAAM,WAAW,yBAAyB;IACxC,8DAA8D;IAC9D,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAC9B,0CAA0C;IAC1C,YAAY,EAAE,gBAAgB,CAAC;IAC/B,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,6DAA6D;AAC7D,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;AAMrF;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,wBAAwB;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B"}

View File

@@ -1,58 +0,0 @@
"use strict";
/**
* HarborForge Calendar — Plugin-side type definitions
*
* PLG-CAL-001: Define the Calendar heartbeat request/response format
* between the OpenClaw plugin and HarborForge backend.
*
* Request flow (plugin → backend):
* POST /calendar/agent/heartbeat
* Headers:
* X-Agent-ID: <agent_id> — OpenClaw $AGENT_ID of the calling agent
* X-Claw-Identifier: <identifier> — HarborForge server identifier
* Body (JSON):
* { "claw_identifier": "...", "agent_id": "..." }
*
* Response flow (backend → plugin):
* Returns list of TimeSlots pending execution for today.
* The plugin uses slot.id / slot.virtual_id to update slot status
* via subsequent API calls.
*
* References:
* • NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism)
* • HarborForge.Backend/app/models/calendar.py — TimeSlot DB model
* • HarborForge.Backend/app/schemas/calendar.py — TimeSlot schemas
* • HarborForge.Backend/app/services/agent_heartbeat.py — BE-AGT-001
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EventType = exports.SlotStatus = exports.SlotType = void 0;
// ---------------------------------------------------------------------------
// Enums (mirror backend enums)
// ---------------------------------------------------------------------------
/** Slot type — mirrors backend SlotType enum */
var SlotType;
(function (SlotType) {
SlotType["WORK"] = "work";
SlotType["ON_CALL"] = "on_call";
SlotType["ENTERTAINMENT"] = "entertainment";
SlotType["SYSTEM"] = "system";
})(SlotType || (exports.SlotType = SlotType = {}));
/** Slot lifecycle status — mirrors backend SlotStatus enum */
var SlotStatus;
(function (SlotStatus) {
SlotStatus["NOT_STARTED"] = "not_started";
SlotStatus["ONGOING"] = "ongoing";
SlotStatus["DEFERRED"] = "deferred";
SlotStatus["SKIPPED"] = "skipped";
SlotStatus["PAUSED"] = "paused";
SlotStatus["FINISHED"] = "finished";
SlotStatus["ABORTED"] = "aborted";
})(SlotStatus || (exports.SlotStatus = SlotStatus = {}));
/** High-level event category — mirrors backend EventType enum */
var EventType;
(function (EventType) {
EventType["JOB"] = "job";
EventType["ENTERTAINMENT"] = "entertainment";
EventType["SYSTEM_EVENT"] = "system_event";
})(EventType || (exports.EventType = EventType = {}));
//# sourceMappingURL=types.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;;AAEH,8EAA8E;AAC9E,+BAA+B;AAC/B,8EAA8E;AAE9E,gDAAgD;AAChD,IAAY,QAKX;AALD,WAAY,QAAQ;IAClB,yBAAa,CAAA;IACb,+BAAmB,CAAA;IACnB,2CAA+B,CAAA;IAC/B,6BAAiB,CAAA;AACnB,CAAC,EALW,QAAQ,wBAAR,QAAQ,QAKnB;AAED,8DAA8D;AAC9D,IAAY,UAQX;AARD,WAAY,UAAU;IACpB,yCAA2B,CAAA;IAC3B,iCAAmB,CAAA;IACnB,mCAAqB,CAAA;IACrB,iCAAmB,CAAA;IACnB,+BAAiB,CAAA;IACjB,mCAAqB,CAAA;IACrB,iCAAmB,CAAA;AACrB,CAAC,EARW,UAAU,0BAAV,UAAU,QAQrB;AAED,iEAAiE;AACjE,IAAY,SAIX;AAJD,WAAY,SAAS;IACnB,wBAAW,CAAA;IACX,4CAA+B,CAAA;IAC/B,0CAA6B,CAAA;AAC/B,CAAC,EAJW,SAAS,yBAAT,SAAS,QAIpB"}

35
plugin/core/config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { hostname } from 'os';
export interface HarborForgePluginConfig {
enabled?: boolean;
backendUrl?: string;
identifier?: string;
apiKey?: string;
monitor_port?: number;
reportIntervalSec?: number;
httpFallbackIntervalSec?: number;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
calendarEnabled?: boolean;
calendarHeartbeatIntervalSec?: number;
calendarApiKey?: string;
managedMonitor?: string;
}
interface PluginApiLike {
pluginConfig?: Record<string, unknown>;
}
export function getPluginConfig(api: PluginApiLike): HarborForgePluginConfig {
const cfg = (api.pluginConfig || {}) as HarborForgePluginConfig;
return {
enabled: true,
backendUrl: 'https://monitor.hangman-lab.top',
identifier: hostname(),
reportIntervalSec: 30,
httpFallbackIntervalSec: 60,
logLevel: 'info',
calendarEnabled: true,
calendarHeartbeatIntervalSec: 60,
...cfg,
};
}

View File

@@ -1,17 +0,0 @@
export interface HarborForgeMonitorConfig {
enabled?: boolean;
backendUrl?: string;
identifier?: string;
apiKey?: string;
monitor_port?: number;
monitorPort?: number;
reportIntervalSec?: number;
httpFallbackIntervalSec?: number;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
}
interface OpenClawPluginApiLike {
config?: Record<string, unknown>;
}
export declare function getLivePluginConfig(api: OpenClawPluginApiLike, fallback: HarborForgeMonitorConfig): HarborForgeMonitorConfig;
export {};
//# sourceMappingURL=live-config.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"live-config.d.ts","sourceRoot":"","sources":["live-config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CAChD;AAED,UAAU,qBAAqB;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,qBAAqB,EAC1B,QAAQ,EAAE,wBAAwB,GACjC,wBAAwB,CAgC1B"}

View File

@@ -1,32 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getLivePluginConfig = getLivePluginConfig;
function getLivePluginConfig(api, fallback) {
const root = api.config || {};
const plugins = root.plugins || {};
const entries = plugins.entries || {};
const entry = entries['harbor-forge'] || {};
const cfg = entry.config || {};
if (Object.keys(cfg).length > 0 || Object.keys(entry).length > 0) {
const monitorPort = typeof cfg.monitor_port === 'number'
? cfg.monitor_port
: typeof cfg.monitorPort === 'number'
? cfg.monitorPort
: typeof fallback.monitor_port === 'number'
? fallback.monitor_port
: fallback.monitorPort;
return {
...fallback,
...cfg,
monitor_port: monitorPort,
monitorPort,
enabled: typeof cfg.enabled === 'boolean'
? cfg.enabled
: typeof entry.enabled === 'boolean'
? entry.enabled
: fallback.enabled,
};
}
return fallback;
}
//# sourceMappingURL=live-config.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"live-config.js","sourceRoot":"","sources":["live-config.ts"],"names":[],"mappings":";;AAgBA,kDAmCC;AAnCD,SAAgB,mBAAmB,CACjC,GAA0B,EAC1B,QAAkC;IAElC,MAAM,IAAI,GAAI,GAAG,CAAC,MAAkC,IAAI,EAAE,CAAC;IAC3D,MAAM,OAAO,GAAI,IAAI,CAAC,OAAmC,IAAI,EAAE,CAAC;IAChE,MAAM,OAAO,GAAI,OAAO,CAAC,OAAmC,IAAI,EAAE,CAAC;IACnE,MAAM,KAAK,GAAI,OAAO,CAAC,cAAc,CAA6B,IAAI,EAAE,CAAC;IACzE,MAAM,GAAG,GAAI,KAAK,CAAC,MAAkC,IAAI,EAAE,CAAC;IAE5D,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACjE,MAAM,WAAW,GACf,OAAO,GAAG,CAAC,YAAY,KAAK,QAAQ;YAClC,CAAC,CAAC,GAAG,CAAC,YAAY;YAClB,CAAC,CAAC,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ;gBACnC,CAAC,CAAC,GAAG,CAAC,WAAW;gBACjB,CAAC,CAAC,OAAO,QAAQ,CAAC,YAAY,KAAK,QAAQ;oBACzC,CAAC,CAAC,QAAQ,CAAC,YAAY;oBACvB,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC;QAE/B,OAAO;YACL,GAAG,QAAQ;YACX,GAAG,GAAG;YACN,YAAY,EAAE,WAAW;YACzB,WAAW;YACX,OAAO,EACL,OAAO,GAAG,CAAC,OAAO,KAAK,SAAS;gBAC9B,CAAC,CAAC,GAAG,CAAC,OAAO;gBACb,CAAC,CAAC,OAAO,KAAK,CAAC,OAAO,KAAK,SAAS;oBAClC,CAAC,CAAC,KAAK,CAAC,OAAO;oBACf,CAAC,CAAC,QAAQ,CAAC,OAAO;SACG,CAAC;IAChC,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"}

View File

@@ -1,52 +0,0 @@
export interface HarborForgeMonitorConfig {
enabled?: boolean;
backendUrl?: string;
identifier?: string;
apiKey?: string;
monitor_port?: number;
monitorPort?: number;
reportIntervalSec?: number;
httpFallbackIntervalSec?: number;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
}
interface OpenClawPluginApiLike {
config?: Record<string, unknown>;
}
export function getLivePluginConfig(
api: OpenClawPluginApiLike,
fallback: HarborForgeMonitorConfig
): HarborForgeMonitorConfig {
const root = (api.config as Record<string, unknown>) || {};
const plugins = (root.plugins as Record<string, unknown>) || {};
const entries = (plugins.entries as Record<string, unknown>) || {};
const entry = (entries['harbor-forge'] as Record<string, unknown>) || {};
const cfg = (entry.config as Record<string, unknown>) || {};
if (Object.keys(cfg).length > 0 || Object.keys(entry).length > 0) {
const monitorPort =
typeof cfg.monitor_port === 'number'
? cfg.monitor_port
: typeof cfg.monitorPort === 'number'
? cfg.monitorPort
: typeof fallback.monitor_port === 'number'
? fallback.monitor_port
: fallback.monitorPort;
return {
...fallback,
...cfg,
monitor_port: monitorPort,
monitorPort,
enabled:
typeof cfg.enabled === 'boolean'
? cfg.enabled
: typeof entry.enabled === 'boolean'
? entry.enabled
: fallback.enabled,
} as HarborForgeMonitorConfig;
}
return fallback;
}

View File

@@ -0,0 +1,59 @@
import { spawn, type ChildProcess } from 'child_process';
import { existsSync } from 'fs';
export interface ManagedMonitorConfig {
managedMonitor?: string;
backendUrl?: string;
identifier?: string;
apiKey?: string;
monitor_port?: number;
reportIntervalSec?: number;
logLevel?: string;
}
let monitorProcess: ChildProcess | null = null;
export function startManagedMonitor(
logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void },
config: ManagedMonitorConfig,
): void {
if (!config.managedMonitor) return;
if (monitorProcess) {
logger.info('HarborForge managed monitor already running');
return;
}
if (!existsSync(config.managedMonitor)) {
logger.warn(`HarborForge managed monitor path not found: ${config.managedMonitor}`);
return;
}
const args: string[] = [];
if (config.backendUrl) args.push('--backend-url', String(config.backendUrl));
if (config.identifier) args.push('--identifier', String(config.identifier));
if (config.apiKey) args.push('--api-key', String(config.apiKey));
if (config.monitor_port) args.push('--monitor-port', String(config.monitor_port));
if (config.reportIntervalSec) args.push('--report-interval', String(config.reportIntervalSec));
if (config.logLevel) args.push('--log-level', String(config.logLevel));
monitorProcess = spawn(config.managedMonitor, args, {
stdio: 'inherit',
detached: false,
});
monitorProcess.on('exit', (code, signal) => {
logger.warn(`HarborForge managed monitor exited code=${code ?? 'null'} signal=${signal ?? 'null'}`);
monitorProcess = null;
});
logger.info(`HarborForge managed monitor started: ${config.managedMonitor}`);
}
export function stopManagedMonitor(
logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void },
): void {
if (!monitorProcess) return;
const pid = monitorProcess.pid;
monitorProcess.kill('SIGTERM');
monitorProcess = null;
logger.info(`HarborForge managed monitor stopped pid=${pid ?? 'unknown'}`);
}

View File

@@ -1,55 +0,0 @@
/**
* Monitor Bridge Client
*
* Queries the local HarborForge.Monitor bridge endpoint on MONITOR_PORT
* to enrich plugin telemetry with host/hardware data.
*
* If the bridge is unreachable, all methods return null gracefully —
* the plugin continues to function without Monitor data.
*/
export interface MonitorHealth {
status: string;
monitor_version: string;
identifier: string;
}
export interface MonitorTelemetryResponse {
status: string;
monitor_version: string;
identifier: string;
telemetry?: {
identifier: string;
plugin_version: string;
cpu_pct: number;
mem_pct: number;
disk_pct: number;
swap_pct: number;
load_avg: number[];
uptime_seconds: number;
nginx_installed: boolean;
nginx_sites: string[];
};
last_updated?: string;
}
export declare class MonitorBridgeClient {
private baseUrl;
private timeoutMs;
constructor(port: number, timeoutMs?: number);
health(): Promise<MonitorHealth | null>;
telemetry(): Promise<MonitorTelemetryResponse | null>;
/**
* POST OpenClaw metadata to the Monitor bridge so it can enrich
* its heartbeat uploads with OpenClaw version, plugin version,
* and agent information.
*/
pushOpenClawMeta(meta: OpenClawMeta): Promise<boolean>;
private fetchJson;
}
/**
* OpenClaw metadata payload sent to the Monitor bridge.
*/
export interface OpenClawMeta {
version: string;
plugin_version: string;
agents?: any[];
}
//# sourceMappingURL=monitor-bridge.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"monitor-bridge.d.ts","sourceRoot":"","sources":["monitor-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE;QACV,UAAU,EAAE,MAAM,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,cAAc,EAAE,MAAM,CAAC;QACvB,eAAe,EAAE,OAAO,CAAC;QACzB,WAAW,EAAE,MAAM,EAAE,CAAC;KACvB,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;gBAEd,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO;IAKpC,MAAM,IAAI,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAIvC,SAAS,IAAI,OAAO,CAAC,wBAAwB,GAAG,IAAI,CAAC;IAI3D;;;;OAIG;IACG,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC;YAmB9C,SAAS;CAgBxB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC;CAChB"}

View File

@@ -1,66 +0,0 @@
"use strict";
/**
* Monitor Bridge Client
*
* Queries the local HarborForge.Monitor bridge endpoint on MONITOR_PORT
* to enrich plugin telemetry with host/hardware data.
*
* If the bridge is unreachable, all methods return null gracefully —
* the plugin continues to function without Monitor data.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.MonitorBridgeClient = void 0;
class MonitorBridgeClient {
baseUrl;
timeoutMs;
constructor(port, timeoutMs = 3000) {
this.baseUrl = `http://127.0.0.1:${port}`;
this.timeoutMs = timeoutMs;
}
async health() {
return this.fetchJson('/health');
}
async telemetry() {
return this.fetchJson('/telemetry');
}
/**
* POST OpenClaw metadata to the Monitor bridge so it can enrich
* its heartbeat uploads with OpenClaw version, plugin version,
* and agent information.
*/
async pushOpenClawMeta(meta) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
const response = await fetch(`${this.baseUrl}/openclaw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
signal: controller.signal,
});
clearTimeout(timeout);
return response.ok;
}
catch {
return false;
}
}
async fetchJson(path) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
const response = await fetch(`${this.baseUrl}${path}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok)
return null;
return (await response.json());
}
catch {
return null;
}
}
}
exports.MonitorBridgeClient = MonitorBridgeClient;
//# sourceMappingURL=monitor-bridge.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"monitor-bridge.js","sourceRoot":"","sources":["monitor-bridge.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;;;AA2BH,MAAa,mBAAmB;IACtB,OAAO,CAAS;IAChB,SAAS,CAAS;IAE1B,YAAY,IAAY,EAAE,SAAS,GAAG,IAAI;QACxC,IAAI,CAAC,OAAO,GAAG,oBAAoB,IAAI,EAAE,CAAC;QAC1C,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;IAED,KAAK,CAAC,MAAM;QACV,OAAO,IAAI,CAAC,SAAS,CAAgB,SAAS,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,OAAO,IAAI,CAAC,SAAS,CAA2B,YAAY,CAAC,CAAC;IAChE,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,gBAAgB,CAAC,IAAkB;QACvC,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YAErE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,WAAW,EAAE;gBACvD,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;gBAC1B,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,YAAY,CAAC,OAAO,CAAC,CAAC;YAEtB,OAAO,QAAQ,CAAC,EAAE,CAAC;QACrB,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IAEO,KAAK,CAAC,SAAS,CAAI,IAAY;QACrC,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;YACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YAErE,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,EAAE;gBACrD,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,YAAY,CAAC,OAAO,CAAC,CAAC;YAEtB,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,OAAO,IAAI,CAAC;YAC9B,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAM,CAAC;QACtC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;CACF;AAzDD,kDAyDC"}

View File

@@ -0,0 +1,48 @@
export interface OpenClawAgentInfo {
name: string;
isDefault?: boolean;
identity?: string;
workspace?: string;
agentDir?: string;
model?: string;
routingRules?: number;
routing?: string;
}
export async function listOpenClawAgents(_logger?: { debug?: (...args: any[]) => void; warn?: (...args: any[]) => void }): Promise<OpenClawAgentInfo[]> {
return [];
}
export function parseOpenClawAgents(text: string): OpenClawAgentInfo[] {
const lines = text.split(/\r?\n/);
const out: OpenClawAgentInfo[] = [];
let current: OpenClawAgentInfo | null = null;
const push = () => { if (current) out.push(current); current = null; };
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim() || line.startsWith("Agents:") || line.startsWith("Routing rules map") || line.startsWith("Channel status reflects")) continue;
if (line.startsWith("- ")) {
push();
const m = line.match(/^-\s+(.+?)(?:\s+\((default)\))?$/);
current = { name: m?.[1] || line.slice(2).trim(), isDefault: m?.[2] === "default" };
continue;
}
if (!current) continue;
const trimmed = line.trim();
const idx = trimmed.indexOf(":");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
switch (key) {
case "Identity": current.identity = value; break;
case "Workspace": current.workspace = value; break;
case "Agent dir": current.agentDir = value; break;
case "Model": current.model = value; break;
case "Routing rules": { const n = Number(value); current.routingRules = Number.isFinite(n) ? n : undefined; break; }
case "Routing": current.routing = value; break;
default: break;
}
}
push();
return out;
}

View File

@@ -0,0 +1,37 @@
import { hostname } from 'os';
import { getPluginConfig } from '../core/config.js';
import { startManagedMonitor } from '../core/managed-monitor.js';
export function registerGatewayStartHook(api: any, deps: {
logger: any;
pushMetaToMonitor: () => Promise<void>;
startCalendarScheduler: () => void;
setMetaPushInterval: (handle: ReturnType<typeof setInterval>) => void;
}) {
const { logger, pushMetaToMonitor, startCalendarScheduler, setMetaPushInterval } = deps;
api.on('gateway_start', () => {
logger.info('HarborForge plugin active');
const live = getPluginConfig(api);
startManagedMonitor(logger, {
managedMonitor: live.managedMonitor,
backendUrl: live.backendUrl,
identifier: live.identifier,
apiKey: live.apiKey,
monitor_port: live.monitor_port,
reportIntervalSec: live.reportIntervalSec,
logLevel: live.logLevel,
});
const intervalSec = live.reportIntervalSec || 30;
setTimeout(() => void pushMetaToMonitor(), 2000);
setMetaPushInterval(setInterval(() => void pushMetaToMonitor(), intervalSec * 1000));
if (live.enabled !== false && live.calendarEnabled !== false) {
setTimeout(() => startCalendarScheduler(), 5000);
}
logger.info(`HarborForge startup config identifier=${live.identifier || hostname()} backend=${live.backendUrl}`);
});
}

View File

@@ -0,0 +1,23 @@
import { stopManagedMonitor } from '../core/managed-monitor.js';
export function registerGatewayStopHook(api: any, deps: {
logger: any;
getMetaPushInterval: () => ReturnType<typeof setInterval> | null;
clearMetaPushInterval: () => void;
stopCalendarScheduler: () => void;
}) {
const { logger, getMetaPushInterval, clearMetaPushInterval, stopCalendarScheduler } = deps;
api.on('gateway_stop', () => {
logger.info('HarborForge plugin stopping');
const handle = getMetaPushInterval();
if (handle) {
clearInterval(handle);
clearMetaPushInterval();
}
stopCalendarScheduler();
stopManagedMonitor(logger);
});
}

View File

@@ -5,21 +5,26 @@
* for the HarborForge Monitor bridge (via monitor_port). * for the HarborForge Monitor bridge (via monitor_port).
* *
* Also integrates with HarborForge Calendar system to wake agents * Also integrates with HarborForge Calendar system to wake agents
* for scheduled tasks (PLG-CAL-002). * for scheduled tasks (PLG-CAL-002, PLG-CAL-004).
* *
* Sidecar architecture has been removed. Telemetry data is now * Sidecar architecture has been removed. Telemetry data is now
* served directly by the plugin when Monitor queries via the * served directly by the plugin when Monitor queries via the
* local monitor_port communication path. * local monitor_port communication path.
*/ */
import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os'; import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'node:os';
import { getLivePluginConfig, type HarborForgeMonitorConfig } from './core/live-config'; import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge'; import { MultiAgentScheduleCache } from './calendar/schedule-cache.js';
import { getPluginConfig } from './core/config.js';
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge.js';
import type { OpenClawAgentInfo } from './core/openclaw-agents.js';
import { registerGatewayStartHook } from './hooks/gateway-start.js';
import { registerGatewayStopHook } from './hooks/gateway-stop.js';
import { import {
createCalendarBridgeClient, createCalendarBridgeClient,
createCalendarScheduler, createCalendarScheduler,
CalendarScheduler, CalendarScheduler,
AgentWakeContext, AgentWakeContext,
} from './calendar'; } from './calendar/index.js';
interface PluginAPI { interface PluginAPI {
logger: { logger: {
@@ -29,6 +34,12 @@ interface PluginAPI {
warn: (...args: any[]) => void; warn: (...args: any[]) => void;
}; };
version?: string; version?: string;
runtime?: {
version?: string;
config?: {
loadConfig?: () => any;
};
};
config?: Record<string, unknown>; config?: Record<string, unknown>;
pluginConfig?: Record<string, unknown>; pluginConfig?: Record<string, unknown>;
on: (event: string, handler: () => void) => void; on: (event: string, handler: () => void) => void;
@@ -44,10 +55,7 @@ interface PluginAPI {
getAgentStatus?: () => Promise<{ status: string } | null>; getAgentStatus?: () => Promise<{ status: string } | null>;
} }
export default { function register(api: PluginAPI): void {
id: 'harbor-forge',
name: 'HarborForge',
register(api: PluginAPI) {
const logger = api.logger || { const logger = api.logger || {
info: (...args: any[]) => console.log('[HarborForge]', ...args), info: (...args: any[]) => console.log('[HarborForge]', ...args),
error: (...args: any[]) => console.error('[HarborForge]', ...args), error: (...args: any[]) => console.error('[HarborForge]', ...args),
@@ -55,27 +63,23 @@ export default {
warn: (...args: any[]) => console.warn('[HarborForge]', ...args), warn: (...args: any[]) => console.warn('[HarborForge]', ...args),
}; };
const baseConfig: HarborForgeMonitorConfig = {
enabled: true,
backendUrl: 'https://monitor.hangman-lab.top',
identifier: '',
reportIntervalSec: 30,
httpFallbackIntervalSec: 60,
logLevel: 'info',
...(api.pluginConfig || {}),
};
function resolveConfig() { function resolveConfig() {
return getLivePluginConfig(api, baseConfig); return getPluginConfig(api);
}
/** Resolve agent ID from env, config, or fallback. */
function resolveAgentId(): string {
if (process.env.AGENT_ID) return process.env.AGENT_ID;
const cfg = api.runtime?.config?.loadConfig?.();
return cfg?.agents?.list?.[0]?.id ?? cfg?.agents?.defaults?.id ?? 'unknown';
} }
/** /**
* Get the monitor bridge client if monitor_port is configured. * Get the monitor bridge client if monitor_port is configured.
* Legacy alias monitorPort is still accepted.
*/ */
function getBridgeClient(): MonitorBridgeClient | null { function getBridgeClient(): MonitorBridgeClient | null {
const live = resolveConfig() as any; const live = resolveConfig();
const port = live.monitor_port ?? live.monitorPort; const port = live.monitor_port;
if (!port || port <= 0) return null; if (!port || port <= 0) return null;
return new MonitorBridgeClient(port); return new MonitorBridgeClient(port);
} }
@@ -104,8 +108,8 @@ export default {
avg15: load[2], avg15: load[2],
}, },
openclaw: { openclaw: {
version: api.version || 'unknown', version: api.runtime?.version || api.version || 'unknown',
pluginVersion: '0.3.0', pluginVersion: '0.3.1', // Bumped for PLG-CAL-004
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -126,10 +130,21 @@ export default {
const bridgeClient = getBridgeClient(); const bridgeClient = getBridgeClient();
if (!bridgeClient) return; if (!bridgeClient) return;
let agentNames: string[] = [];
try {
const cfg = api.runtime?.config?.loadConfig?.();
const agentsList = cfg?.agents?.list;
if (Array.isArray(agentsList)) {
agentNames = agentsList
.map((a: any) => typeof a === 'string' ? a : a?.name)
.filter(Boolean);
}
} catch { /* non-fatal */ }
const meta: OpenClawMeta = { const meta: OpenClawMeta = {
version: api.version || 'unknown', version: api.runtime?.version || api.version || 'unknown',
plugin_version: '0.3.0', plugin_version: '0.3.1',
agents: [], // TODO: populate from api agent list when available agents: agentNames.map(name => ({ name })),
}; };
const ok = await bridgeClient.pushOpenClawMeta(meta); const ok = await bridgeClient.pushOpenClawMeta(meta);
@@ -159,7 +174,7 @@ export default {
// Fallback: query backend for agent status // Fallback: query backend for agent status
const live = resolveConfig(); const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown'; const agentId = resolveAgentId();
try { try {
const response = await fetch(`${live.backendUrl}/calendar/agent/status?agent_id=${agentId}`, { const response = await fetch(`${live.backendUrl}/calendar/agent/status?agent_id=${agentId}`, {
headers: { headers: {
@@ -179,56 +194,88 @@ export default {
} }
/** /**
* Wake/spawn agent with task context for slot execution. * Wake agent via dispatchInboundMessage — same mechanism used by Discord plugin.
* This is the callback invoked by CalendarScheduler when a slot is ready. * Direct in-process call, no WebSocket or CLI needed.
*/ */
async function wakeAgent(context: AgentWakeContext): Promise<boolean> { async function wakeAgent(
logger.info(`Waking agent for slot: ${context.taskDescription}`); agentId: string,
dueSlots?: Array<{
id?: number | null;
virtual_id?: string | null;
event_data?: any;
scheduled_at?: string;
priority?: number;
slot_type?: string;
[k: string]: unknown;
}>
): Promise<boolean> {
logger.info(`Waking agent ${agentId}: has due slots`);
const sessionKey = `agent:${agentId}:hf-wakeup`;
try { try {
// Method 1: Use OpenClaw spawn API if available (preferred) const sdkPath = 'openclaw/plugin-sdk/reply-runtime';
if (api.spawn) { const { dispatchInboundMessageWithDispatcher } = await import(
const result = await api.spawn({ /* webpackIgnore: true */ sdkPath
task: context.prompt, );
timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds
});
if (result?.sessionId) { // api.config first (current public API). Fall back to deprecated
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`); // runtime.config.loadConfig() for older host versions. Both should
// contain agents.list / channels for dispatch routing.
// Track session completion const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
trackSessionCompletion(result.sessionId, context); if (!cfg) {
return true; logger.error('Cannot load OpenClaw config for dispatch');
} return false;
} }
// Method 2: Send notification/alert to wake agent (fallback) // Inline the highest-priority due slot's context so the agent does
// This relies on the agent's heartbeat to check for notifications // not need a second round-trip to harborforge_calendar_status. The
logger.warn('OpenClaw spawn API not available, using notification fallback'); // agent can read event_data.task_code / task_title etc. directly.
let slotBlock = '';
const top = dueSlots && dueSlots.length ? dueSlots[0] : undefined;
if (top) {
slotBlock = `\n\nMatching slot:\n\`\`\`json\n${JSON.stringify(
{
slot_id: top.id ?? null,
virtual_id: top.virtual_id ?? null,
scheduled_at: top.scheduled_at ?? null,
priority: top.priority ?? null,
slot_type: top.slot_type ?? null,
event_data: top.event_data ?? null,
},
null,
2
)}\n\`\`\``;
}
// Send calendar wakeup notification via backend const wakeupMessage =
const live = resolveConfig(); `You have due slots. Follow the \`hf-wakeup\` workflow of skill ` +
const agentId = process.env.AGENT_ID || 'unknown'; `\`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this ` +
`session.${slotBlock}`;
const notifyResponse = await fetch(`${live.backendUrl}/calendar/agent/notify`, { const result = await dispatchInboundMessageWithDispatcher({
method: 'POST', ctx: {
headers: { Body: wakeupMessage,
'Content-Type': 'application/json', SessionKey: sessionKey,
'X-Agent-ID': agentId, From: 'harborforge-calendar',
'X-Claw-Identifier': live.identifier || hostname(), Provider: 'harborforge',
},
cfg,
dispatcherOptions: {
deliver: async (payload: any) => {
const text = (payload.text || '').trim();
logger.info(`Agent ${agentId} wakeup reply: ${text.slice(0, 100)}`);
},
}, },
body: JSON.stringify({
agent_id: agentId,
message: context.prompt,
slot_id: context.slot.id || context.slot.virtual_id,
task_description: context.taskDescription,
}),
}); });
return notifyResponse.ok; logger.info(`Agent ${agentId} dispatched: ${result?.status || 'ok'}`);
return true;
} catch (err) { } catch (err: any) {
logger.error('Failed to wake agent:', err); const msg = err?.message || err?.code || String(err);
const stack = err?.stack?.split('\n').slice(0, 3).join(' | ') || '';
logger.error(`Failed to dispatch agent for slot: ${msg} ${stack}`);
return false; return false;
} }
} }
@@ -270,27 +317,99 @@ export default {
*/ */
function startCalendarScheduler(): void { function startCalendarScheduler(): void {
const live = resolveConfig(); const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
// Create calendar bridge client // Create bridge client (claw-instance level, not per-agent)
const calendarBridge = createCalendarBridgeClient( const calendarBridge = createCalendarBridgeClient(
api, api,
live.backendUrl || 'https://monitor.hangman-lab.top', live.backendUrl || 'https://monitor.hangman-lab.top',
agentId 'unused' // agentId no longer needed at bridge level
); );
// Create and start scheduler // Multi-agent sync + check loop
calendarScheduler = createCalendarScheduler({ const scheduleCache = new MultiAgentScheduleCache();
bridge: calendarBridge,
getAgentStatus,
wakeAgent,
logger,
heartbeatIntervalMs: 60000, // 1 minute
debug: live.logLevel === 'debug',
});
calendarScheduler.start(); const SYNC_INTERVAL_MS = 300_000; // 5 min
logger.info('Calendar scheduler started'); const CHECK_INTERVAL_MS = 30_000; // 30 sec
// Sync: pull all agent schedules from backend
async function runSync() {
try {
const result = await calendarBridge.syncSchedules();
if (result) {
scheduleCache.sync(result.date, result.schedules);
const status = scheduleCache.getStatus();
logger.info(`Schedule synced: ${status.agentCount} agents, ${status.totalSlots} slots`);
}
} catch (err) {
logger.warn(`Schedule sync failed: ${String(err)}`);
}
}
// Track wakes already dispatched for a slot in the current sync
// window — the simplified inline scheduler does not PATCH slot
// status server-side, so without dedupe the check loop re-wakes
// the same slot every 30s. Set is cleared by runSync (fresh wake
// budget per sync).
const wakedSlotKeys = new Set<string>();
// Check: find agents with due slots and wake them
async function runCheck() {
const now = new Date();
const agentsWithDue = scheduleCache.getAgentsWithDueSlots(now);
for (const { agentId, slots } of agentsWithDue) {
// Filter out slots we've already woken this sync window
const fresh = slots.filter((s) => {
const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`;
if (wakedSlotKeys.has(key)) return false;
return true;
});
if (fresh.length === 0) continue;
// Check if agent is busy (best effort; backend may 405 the GET
// — treat unknown as not-busy so wakeup still fires)
let status: string | null = null;
try {
status = await calendarBridge.getAgentStatus(agentId);
} catch {
status = null;
}
if (status === 'busy' || status === 'offline' || status === 'exhausted') {
continue;
}
// Wake the agent with the slot context inlined
const ok = await wakeAgent(agentId, fresh);
if (ok) {
for (const s of fresh) {
const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`;
wakedSlotKeys.add(key);
}
}
}
}
// Initial sync (also resets the wake-dedupe window)
const runSyncReset = async () => {
wakedSlotKeys.clear();
await runSync();
};
runSyncReset();
// Start intervals
const syncHandle = setInterval(runSyncReset, SYNC_INTERVAL_MS);
const checkHandle = setInterval(runCheck, CHECK_INTERVAL_MS);
// Store handles for cleanup (reuse calendarScheduler variable)
(calendarScheduler as any) = {
stop() {
clearInterval(syncHandle);
clearInterval(checkHandle);
logger.info('Calendar scheduler stopped');
},
};
logger.info('Calendar scheduler started (multi-agent sync mode)');
} }
/** /**
@@ -304,38 +423,24 @@ export default {
} }
} }
api.on('gateway_start', () => { registerGatewayStartHook(api, {
logger.info('HarborForge plugin active'); logger,
pushMetaToMonitor,
// Push metadata to Monitor bridge on startup and periodically. startCalendarScheduler,
// Interval aligns with typical Monitor heartbeat cycle (30s). setMetaPushInterval(handle) {
// If Monitor bridge is unreachable, pushes silently fail. metaPushInterval = handle;
const live = resolveConfig(); },
const intervalSec = live.reportIntervalSec || 30;
// Initial push (delayed 2s to let Monitor bridge start)
setTimeout(() => pushMetaToMonitor(), 2000);
metaPushInterval = setInterval(
() => pushMetaToMonitor(),
intervalSec * 1000,
);
// Start calendar scheduler (delayed to let everything initialize)
if (live.enabled !== false) {
setTimeout(() => startCalendarScheduler(), 5000);
}
}); });
api.on('gateway_stop', () => { registerGatewayStopHook(api, {
logger.info('HarborForge plugin stopping'); logger,
getMetaPushInterval() {
if (metaPushInterval) { return metaPushInterval;
clearInterval(metaPushInterval); },
clearMetaPushInterval() {
metaPushInterval = null; metaPushInterval = null;
} },
stopCalendarScheduler,
stopCalendarScheduler();
}); });
// Tool: plugin status // Tool: plugin status
@@ -363,6 +468,7 @@ export default {
running: calendarScheduler.isRunning(), running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(), processing: calendarScheduler.isProcessing(),
currentSlot: calendarScheduler.getCurrentSlot(), currentSlot: calendarScheduler.getCurrentSlot(),
isRestartPending: calendarScheduler.isRestartPending(),
} : null; } : null;
return { return {
@@ -370,7 +476,7 @@ export default {
config: { config: {
backendUrl: live.backendUrl, backendUrl: live.backendUrl,
identifier: live.identifier || hostname(), identifier: live.identifier || hostname(),
monitorPort: (live as any).monitor_port ?? (live as any).monitorPort ?? null, monitorPort: live.monitor_port ?? null,
reportIntervalSec: live.reportIntervalSec, reportIntervalSec: live.reportIntervalSec,
hasApiKey: Boolean(live.apiKey), hasApiKey: Boolean(live.apiKey),
}, },
@@ -439,6 +545,8 @@ export default {
processing: calendarScheduler.isProcessing(), processing: calendarScheduler.isProcessing(),
currentSlot: calendarScheduler.getCurrentSlot(), currentSlot: calendarScheduler.getCurrentSlot(),
state: calendarScheduler.getState(), state: calendarScheduler.getState(),
isRestartPending: calendarScheduler.isRestartPending(),
stateFilePath: calendarScheduler.getStateFilePath(),
}; };
}, },
})); }));
@@ -490,6 +598,79 @@ export default {
}, },
})); }));
// Tool: pause current slot
api.registerTool(() => ({
name: 'harborforge_calendar_pause',
description: 'Pause the current calendar slot',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
await calendarScheduler.pauseCurrentSlot();
return { success: true, message: 'Slot paused' };
},
}));
// Tool: resume current slot
api.registerTool(() => ({
name: 'harborforge_calendar_resume',
description: 'Resume the paused calendar slot',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
await calendarScheduler.resumeCurrentSlot();
return { success: true, message: 'Slot resumed' };
},
}));
// Tool: check ScheduledGatewayRestart status
api.registerTool(() => ({
name: 'harborforge_restart_status',
description: 'Check if a gateway restart is pending (PLG-CAL-004)',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
const isPending = calendarScheduler.isRestartPending();
const stateFilePath = calendarScheduler.getStateFilePath();
return {
isRestartPending: isPending,
stateFilePath: stateFilePath,
message: isPending
? 'A gateway restart has been scheduled. The scheduler has been paused.'
: 'No gateway restart is pending.',
};
},
}));
logger.info('HarborForge plugin registered (id: harbor-forge)'); logger.info('HarborForge plugin registered (id: harbor-forge)');
}, }
};
// HarborForge's local PluginAPI is broader than the standard OpenClawPluginApi
// (it surfaces optional `version`/`runtime`/`spawn` accessors that older
// OpenClaw builds exposed). The cast at the definePluginEntry boundary
// acknowledges that gap — the runtime api object is whatever the gateway
// passes us, and each access is guarded with optional chaining / fallbacks.
export default definePluginEntry({
id: 'harbor-forge',
name: 'HarborForge',
description: 'HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration',
register: register as (api: any) => void,
});

25
plugin/openclaw-sdk.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// Ambient declarations for the focused subpaths of the openclaw plugin SDK
// that this plugin needs at compile time.
//
// We intentionally do NOT take a `dependencies` (or `devDependencies`) entry
// on the openclaw npm package itself: openclaw is provided by the host
// gateway at runtime, and listing it as a file:/.../openclaw devDep breaks
// the installer's `npm install --omit=dev` step because npm/arborist trips
// over openclaw's own (deeply nested) dependency graph.
//
// These declarations cover only what we use here. They are deliberately
// permissive — the runtime contract is whatever the gateway hands us, and
// we guard each api access with optional chaining or a fallback at call site.
declare module 'openclaw/plugin-sdk/plugin-entry' {
export function definePluginEntry<T extends {
id: string;
name: string;
description?: string;
register: (api: any) => void | Promise<void>;
}>(opts: T): T;
}
declare module 'openclaw/plugin-sdk/core' {
export type OpenClawPluginApi = unknown;
}

View File

@@ -1,9 +1,23 @@
{ {
"id": "harbor-forge", "id": "harbor-forge",
"name": "HarborForge", "name": "HarborForge",
"version": "0.2.0",
"description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration", "description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration",
"entry": "./index.js", "activation": {
"onStartup": true
},
"contracts": {
"tools": [
"harborforge_status",
"harborforge_telemetry",
"harborforge_monitor_telemetry",
"harborforge_calendar_status",
"harborforge_calendar_complete",
"harborforge_calendar_abort",
"harborforge_calendar_pause",
"harborforge_calendar_resume",
"harborforge_restart_status"
]
},
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -59,6 +73,10 @@
"calendarApiKey": { "calendarApiKey": {
"type": "string", "type": "string",
"description": "API key for Calendar API authentication. If not set, uses apiKey or plugin auto-authentication via X-Agent-ID header." "description": "API key for Calendar API authentication. If not set, uses apiKey or plugin auto-authentication via X-Agent-ID header."
},
"managedMonitor": {
"type": "string",
"description": "Absolute path to an installed HarborForge.Monitor binary managed by this plugin installer. If set, gateway_start/gateway_stop hooks will start/stop the monitor process automatically."
} }
} }
} }

View File

@@ -9,14 +9,14 @@
"version": "0.2.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.19.41",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -2,13 +2,15 @@
"name": "harbor-forge-plugin", "name": "harbor-forge-plugin",
"version": "0.2.0", "version": "0.2.0",
"description": "OpenClaw plugin for HarborForge monitor bridge and CLI integration", "description": "OpenClaw plugin for HarborForge monitor bridge and CLI integration",
"main": "index.js", "type": "module",
"main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc", "clean": "rm -rf dist",
"build": "npm run clean && tsc",
"watch": "tsc --watch" "watch": "tsc --watch"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.19.41",
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"license": "MIT" "license": "MIT"

View File

@@ -1,17 +1,16 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "CommonJS", "module": "nodenext",
"moduleResolution": "node", "moduleResolution": "nodenext",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "./", "outDir": "./dist",
"rootDir": "./", "rootDir": "./",
"declaration": true, "declaration": false,
"declarationMap": true,
"sourceMap": true "sourceMap": true
}, },
"include": ["**/*.ts"], "include": ["**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -30,6 +30,7 @@ const PLUGIN_NAME = 'harbor-forge';
const OLD_PLUGIN_NAME = 'harborforge-monitor'; const OLD_PLUGIN_NAME = 'harborforge-monitor';
const PLUGIN_SRC_DIR = join(__dirname, 'plugin'); const PLUGIN_SRC_DIR = join(__dirname, 'plugin');
const SKILLS_SRC_DIR = join(__dirname, 'skills'); const SKILLS_SRC_DIR = join(__dirname, 'skills');
const MONITOR_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Monitor.git';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const options = { const options = {
@@ -40,6 +41,8 @@ const options = {
uninstall: args.includes('--uninstall'), uninstall: args.includes('--uninstall'),
installOnly: args.includes('--install'), installOnly: args.includes('--install'),
installCli: args.includes('--install-cli'), installCli: args.includes('--install-cli'),
installMonitor: 'no',
monitorBranch: 'main',
}; };
const profileIdx = args.indexOf('--openclaw-profile-path'); const profileIdx = args.indexOf('--openclaw-profile-path');
@@ -47,6 +50,16 @@ if (profileIdx !== -1 && args[profileIdx + 1]) {
options.openclawProfilePath = resolve(args[profileIdx + 1]); options.openclawProfilePath = resolve(args[profileIdx + 1]);
} }
const installMonitorIdx = args.indexOf('--install-monitor');
if (installMonitorIdx !== -1 && args[installMonitorIdx + 1]) {
options.installMonitor = String(args[installMonitorIdx + 1]).toLowerCase();
}
const monitorBranchIdx = args.indexOf('--monitor-branch');
if (monitorBranchIdx !== -1 && args[monitorBranchIdx + 1]) {
options.monitorBranch = String(args[monitorBranchIdx + 1]);
}
function resolveOpenclawPath() { function resolveOpenclawPath() {
if (options.openclawProfilePath) return options.openclawProfilePath; if (options.openclawProfilePath) return options.openclawProfilePath;
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH); if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
@@ -85,6 +98,14 @@ function setOpenclawConfig(key, value) {
exec(`openclaw config set ${key} '${JSON.stringify(value)}' --json`, { silent: true }); exec(`openclaw config set ${key} '${JSON.stringify(value)}' --json`, { silent: true });
} }
function assertConfigValue(key, predicate, description) {
const value = getOpenclawConfig(key, undefined);
if (!predicate(value)) {
throw new Error(`Config verification failed for ${key}: ${description}`);
}
return value;
}
function unsetOpenclawConfig(key) { function unsetOpenclawConfig(key) {
try { exec(`openclaw config unset ${key}`, { silent: true }); } catch {} try { exec(`openclaw config unset ${key}`, { silent: true }); } catch {}
} }
@@ -100,6 +121,10 @@ function copyDir(src, dest, { exclude = [] } = {}) {
} }
} }
function shellEscape(value) {
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}
function detectEnvironment() { function detectEnvironment() {
const totalSteps = options.installCli ? 6 : 5; const totalSteps = options.installCli ? 6 : 5;
logStep(1, totalSteps, 'Detecting environment...'); logStep(1, totalSteps, 'Detecting environment...');
@@ -164,6 +189,26 @@ async function build() {
logOk('plugin compiled'); logOk('plugin compiled');
} }
function installManagedMonitor(openclawPath) {
if (options.installMonitor !== 'yes') return null;
const tmpDir = join('/tmp', `harborforge-monitor-${Date.now()}`);
const monitorDestDir = join(openclawPath, 'plugins', PLUGIN_NAME, 'bin');
const binaryPath = join(monitorDestDir, 'HarborForge.Monitor');
mkdirSync(monitorDestDir, { recursive: true });
try {
exec(`git clone --branch ${shellEscape(options.monitorBranch)} ${shellEscape(MONITOR_REPO_URL)} ${shellEscape(tmpDir)}`, { silent: !options.verbose });
exec(`go build -o ${shellEscape(binaryPath)} ./cmd/harborforge-monitor`, { cwd: tmpDir, silent: !options.verbose });
chmodSync(binaryPath, 0o755);
logOk(`Managed monitor installed → ${binaryPath} (branch hint: ${options.monitorBranch})`);
return binaryPath;
} catch (err) {
logWarn(`Managed monitor build failed: ${err.message}`);
return null;
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
function clearInstallTargets(openclawPath) { function clearInstallTargets(openclawPath) {
// Remove new plugin dir // Remove new plugin dir
const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); const destDir = join(openclawPath, 'plugins', PLUGIN_NAME);
@@ -244,7 +289,7 @@ async function install() {
// Copy compiled plugin (no server directory — sidecar removed) // Copy compiled plugin (no server directory — sidecar removed)
mkdirSync(destDir, { recursive: true }); mkdirSync(destDir, { recursive: true });
copyDir(PLUGIN_SRC_DIR, destDir); copyDir(PLUGIN_SRC_DIR, destDir, { exclude: ['node_modules', '.git'] });
logOk(`Plugin files → ${destDir}`); logOk(`Plugin files → ${destDir}`);
// Copy skills (exclude hf/ unless --install-cli) // Copy skills (exclude hf/ unless --install-cli)
@@ -263,7 +308,8 @@ async function install() {
exec('npm install --omit=dev', { cwd: destDir, silent: !options.verbose }); exec('npm install --omit=dev', { cwd: destDir, silent: !options.verbose });
logOk('Runtime deps installed'); logOk('Runtime deps installed');
return { destDir }; const managedMonitorPath = installManagedMonitor(openclawPath);
return { destDir, managedMonitorPath };
} }
async function installCli() { async function installCli() {
@@ -321,6 +367,7 @@ async function configure() {
paths.push(destDir); paths.push(destDir);
setOpenclawConfig('plugins.load.paths', paths); setOpenclawConfig('plugins.load.paths', paths);
} }
assertConfigValue('plugins.load.paths', (value) => Array.isArray(value) && value.includes(destDir), `missing ${destDir}`);
logOk(`plugins.load.paths includes ${destDir}`); logOk(`plugins.load.paths includes ${destDir}`);
const allow = getOpenclawConfig('plugins.allow', []); const allow = getOpenclawConfig('plugins.allow', []);
@@ -328,12 +375,35 @@ async function configure() {
allow.push(PLUGIN_NAME); allow.push(PLUGIN_NAME);
setOpenclawConfig('plugins.allow', allow); setOpenclawConfig('plugins.allow', allow);
} }
assertConfigValue('plugins.allow', (value) => Array.isArray(value) && value.includes(PLUGIN_NAME), `missing ${PLUGIN_NAME}`);
logOk(`plugins.allow includes ${PLUGIN_NAME}`); logOk(`plugins.allow includes ${PLUGIN_NAME}`);
const enabledKey = `plugins.entries.${PLUGIN_NAME}.enabled`;
const configEnabledKey = `plugins.entries.${PLUGIN_NAME}.config.enabled`;
if (getOpenclawConfig(enabledKey, undefined) === undefined) {
setOpenclawConfig(enabledKey, true);
}
if (getOpenclawConfig(configEnabledKey, undefined) === undefined) {
setOpenclawConfig(configEnabledKey, true);
}
assertConfigValue(enabledKey, (value) => value === true, 'expected true');
assertConfigValue(configEnabledKey, (value) => value === true, 'expected true');
if (options.installMonitor === 'yes') {
const binaryPath = join(openclawPath, 'plugins', PLUGIN_NAME, 'bin', 'HarborForge.Monitor');
const managedMonitorKey = `plugins.entries.${PLUGIN_NAME}.config.managedMonitor`;
if (getOpenclawConfig(managedMonitorKey, undefined) === undefined) {
setOpenclawConfig(managedMonitorKey, binaryPath);
}
assertConfigValue(managedMonitorKey, (value) => value === binaryPath, `expected ${binaryPath}`);
logOk(`managedMonitor configured → ${binaryPath}`);
}
logOk('Plugin configured (remember to set apiKey in plugins.entries.harbor-forge.config)'); logOk('Plugin configured (remember to set apiKey in plugins.entries.harbor-forge.config)');
} catch (err) { } catch (err) {
logWarn(`Config failed: ${err.message}`); logErr(`Config failed: ${err.message}`);
throw err;
} }
} }
@@ -354,7 +424,7 @@ function summary() {
log('Next steps:', 'blue'); log('Next steps:', 'blue');
log(' 1. Register server in HarborForge Monitor to get apiKey', 'cyan'); log(' 1. Register server in HarborForge Monitor to get apiKey', 'cyan');
log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harbor-forge.config:', 'cyan'); log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harbor-forge.config:', 'cyan');
log(' (prefer monitor_port; legacy monitorPort is still accepted)', 'cyan'); log(' (monitor_port is required for the local monitor bridge)', 'cyan');
log(' {', 'cyan'); log(' {', 'cyan');
log(' "plugins": {', 'cyan'); log(' "plugins": {', 'cyan');
log(' "entries": {', 'cyan'); log(' "entries": {', 'cyan');
@@ -390,6 +460,12 @@ async function uninstall() {
rmSync(hfBinary, { force: true }); rmSync(hfBinary, { force: true });
logOk('Removed hf CLI binary'); logOk('Removed hf CLI binary');
} }
const managedDir = join(openclawPath, 'plugins', PLUGIN_NAME, 'bin');
if (existsSync(managedDir)) {
rmSync(managedDir, { recursive: true, force: true });
logOk('Removed managed HarborForge monitor');
}
log('\nRun: openclaw gateway restart', 'yellow'); log('\nRun: openclaw gateway restart', 'yellow');
} }