@ahyi/restart-continuity 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Restart Continuity Plugin
2
+
3
+ OpenClaw plugin for **gateway restart continuity**:
4
+
5
+ - runs resumable startup checks when the plugin service starts
6
+ - can health-check and replay selected cron jobs
7
+ - writes a structured continuity state file
8
+ - writes a human-readable daily memory log
9
+ - can hand off a one-shot startup receipt during agent bootstrap
10
+
11
+ ## Install
12
+
13
+ ### Local path
14
+
15
+ ```bash
16
+ openclaw --profile <profile> plugins install /path/to/restart-continuity-plugin
17
+ openclaw --profile <profile> plugins enable restart-continuity
18
+ ```
19
+
20
+ ### npm registry
21
+
22
+ ```bash
23
+ openclaw --profile <profile> plugins install @ahyi/restart-continuity@0.3.1
24
+ openclaw --profile <profile> plugins enable restart-continuity
25
+ ```
26
+
27
+ ## What it does
28
+
29
+ This plugin is designed to replace scattered hook-based restart continuity setups with one installable extension.
30
+
31
+ On plugin service start it can:
32
+
33
+ 1. initialize its runtime marker/state files
34
+ 2. run configured startup resumers
35
+ 3. write `memory/restart-continuity-state.json`
36
+ 4. append a summary to `memory/YYYY-MM-DD.md`
37
+ 5. stage a one-time startup receipt for bootstrap delivery
38
+
39
+ ## Example config
40
+
41
+ ```json
42
+ {
43
+ "plugins": {
44
+ "entries": {
45
+ "restart-continuity": {
46
+ "enabled": true,
47
+ "config": {
48
+ "profile": "mh",
49
+ "workspaceDir": "/root/.openclaw/workspace-mh",
50
+ "logToDailyMemory": true,
51
+ "notifyOnBootstrap": true,
52
+ "resumers": [
53
+ {
54
+ "id": "nightly-learnings",
55
+ "kind": "cron-healthcheck",
56
+ "name": "mh-nightly-learnings-check",
57
+ "jobId": "38a8b8cb-c25a-4bc2-a8a1-d252f9d933ec"
58
+ },
59
+ {
60
+ "id": "weekly-self-improve",
61
+ "kind": "cron-healthcheck",
62
+ "name": "mh-weekly-self-improve",
63
+ "jobId": "6e912522-0591-4307-a155-a6ece496be9c"
64
+ }
65
+ ]
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## Uninstall
74
+
75
+ ```bash
76
+ openclaw --profile <profile> plugins uninstall restart-continuity
77
+ ```
78
+
79
+ OpenClaw removes the plugin config entry and install record. If you want a fully clean teardown, also delete plugin runtime artifacts such as:
80
+
81
+ - `memory/restart-continuity-state.json`
82
+ - `memory/restart-continuity-installed.json`
83
+ - `memory/restart-continuity-receipt.json`
84
+
85
+ ## Publish
86
+
87
+ ```bash
88
+ npm publish --access public
89
+ ```
90
+
91
+ If scoped publishing is used, make sure the npm account/org owns the scope.
package/index.ts ADDED
@@ -0,0 +1,313 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const OPENCLAW_BIN = process.env.OPENCLAW_BIN || "openclaw";
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function normalizeString(value, fallback = "") {
14
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
15
+ }
16
+
17
+ function getWorkspaceDir(api) {
18
+ const pluginConfig = api.pluginConfig || {};
19
+ const explicit = normalizeString(pluginConfig.workspaceDir, "");
20
+ if (explicit) return explicit;
21
+ return normalizeString(api.config?.workspace?.dir, process.cwd());
22
+ }
23
+
24
+ function getProfile(api) {
25
+ const pluginConfig = api.pluginConfig || {};
26
+ return normalizeString(pluginConfig.profile, "mh");
27
+ }
28
+
29
+ function resolvePath(workspaceDir, filePath, fallbackRelative) {
30
+ const raw = normalizeString(filePath, fallbackRelative);
31
+ if (path.isAbsolute(raw)) return raw;
32
+ return path.join(workspaceDir, raw);
33
+ }
34
+
35
+ async function readJsonSafe(filePath, fallback = null) {
36
+ try {
37
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
38
+ } catch {
39
+ return fallback;
40
+ }
41
+ }
42
+
43
+ async function ensureDir(filePath) {
44
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
45
+ }
46
+
47
+ async function writeJson(filePath, data) {
48
+ await ensureDir(filePath);
49
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
50
+ }
51
+
52
+ async function appendText(filePath, text) {
53
+ await ensureDir(filePath);
54
+ await fs.appendFile(filePath, text, "utf8");
55
+ }
56
+
57
+ async function fileExists(filePath) {
58
+ try {
59
+ await fs.access(filePath);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function runOpenclaw(profile, args, timeoutMs = 180000) {
67
+ const finalArgs = profile ? ["--profile", profile, ...args] : args;
68
+ const { stdout } = await execFileAsync(OPENCLAW_BIN, finalArgs, {
69
+ timeout: timeoutMs,
70
+ maxBuffer: 1024 * 1024,
71
+ });
72
+ return String(stdout || "").trim();
73
+ }
74
+
75
+ function parseCronRunsJson(raw) {
76
+ try {
77
+ const obj = JSON.parse(raw);
78
+ if (Array.isArray(obj?.entries) && obj.entries.length > 0) return obj.entries[0];
79
+ if (Array.isArray(obj) && obj.length > 0) return obj[0];
80
+ return null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function buildReceipt(summary) {
87
+ return `启动自检完成:检查 ${summary.checked} 个任务,健康 ${summary.healthy.length},续跑 ${summary.resumed.length},跳过 ${summary.skipped.length},错误 ${summary.errors.length}。`;
88
+ }
89
+
90
+ function buildDailyLog(summary) {
91
+ const lines = [
92
+ `\n## Restart continuity check (${summary.timestampLocal || summary.timestamp})`,
93
+ "",
94
+ `- Checked jobs: ${summary.checked}`,
95
+ `- Already healthy: ${summary.healthy.length ? summary.healthy.join(", ") : "none"}`,
96
+ `- Resumed jobs: ${summary.resumed.length ? summary.resumed.join(", ") : "none"}`,
97
+ `- Skipped jobs: ${summary.skipped.length ? summary.skipped.join(", ") : "none"}`,
98
+ `- Errors: ${summary.errors.length ? summary.errors.join(" | ") : "none"}`,
99
+ `- Outcome: ${summary.errors.length ? (summary.resumed.length ? "partial" : "error") : (summary.resumed.length ? "resumed" : "no-op")}`,
100
+ "",
101
+ ];
102
+ return lines.join("\n");
103
+ }
104
+
105
+ function getDatePartsForShanghai(date = new Date()) {
106
+ const rendered = new Intl.DateTimeFormat("en-CA", {
107
+ timeZone: "Asia/Shanghai",
108
+ year: "numeric",
109
+ month: "2-digit",
110
+ day: "2-digit",
111
+ hour: "2-digit",
112
+ minute: "2-digit",
113
+ second: "2-digit",
114
+ hour12: false,
115
+ }).formatToParts(date);
116
+ const map = Object.fromEntries(rendered.filter((p) => p.type !== "literal").map((p) => [p.type, p.value]));
117
+ return {
118
+ date: `${map.year}-${map.month}-${map.day}`,
119
+ local: `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}:${map.second} Asia/Shanghai`,
120
+ };
121
+ }
122
+
123
+ async function runCronHealthcheck(profile, resumer) {
124
+ const successStatuses = Array.isArray(resumer.successStatuses) && resumer.successStatuses.length
125
+ ? resumer.successStatuses.map(String)
126
+ : ["ok"];
127
+ const runTimeoutMs = Number.isInteger(resumer.runTimeoutMs) ? resumer.runTimeoutMs : 120000;
128
+
129
+ const runsRaw = await runOpenclaw(profile, ["cron", "runs", "--id", String(resumer.jobId), "--limit", "1"], 120000);
130
+ const latest = parseCronRunsJson(runsRaw);
131
+ const latestStatus = latest && typeof latest.status === "string" ? latest.status : null;
132
+
133
+ if (latestStatus && successStatuses.includes(latestStatus)) {
134
+ return { status: "healthy", detail: `${resumer.name || resumer.id}`, latestStatus };
135
+ }
136
+
137
+ if (resumer.autoResume === false) {
138
+ return { status: "skipped", detail: `${resumer.name || resumer.id} (latest=${latestStatus || "none"})`, latestStatus };
139
+ }
140
+
141
+ const runRaw = await runOpenclaw(profile, ["cron", "run", String(resumer.jobId), "--expect-final", "--timeout", String(runTimeoutMs)], Math.max(runTimeoutMs + 20000, 30000));
142
+ const normalized = /\bok\b/i.test(runRaw) ? "ok" : "ran";
143
+ return { status: "resumed", detail: `${resumer.name || resumer.id} => ${normalized}`, latestStatus };
144
+ }
145
+
146
+ async function ensureConfigInitialized(api) {
147
+ const workspaceDir = getWorkspaceDir(api);
148
+ const pluginConfig = api.pluginConfig || {};
149
+ const stateFile = resolvePath(workspaceDir, pluginConfig.stateFile, "memory/restart-continuity-state.json");
150
+ const defaultsFile = resolvePath(workspaceDir, pluginConfig.defaultsFile, "plugins/restart-continuity.defaults.json");
151
+ const markerFile = resolvePath(workspaceDir, pluginConfig.markerFile, "memory/restart-continuity-installed.json");
152
+
153
+ if (await fileExists(markerFile)) return;
154
+
155
+ const defaults = await readJsonSafe(defaultsFile, null);
156
+ await writeJson(markerFile, {
157
+ installedAt: nowIso(),
158
+ source: "restart-continuity",
159
+ workspaceDir,
160
+ defaultsApplied: Boolean(defaults),
161
+ });
162
+
163
+ if (!(await fileExists(stateFile))) {
164
+ await writeJson(stateFile, {
165
+ version: 1,
166
+ initializedAt: nowIso(),
167
+ status: "initialized",
168
+ source: "restart-continuity",
169
+ defaultsApplied: Boolean(defaults),
170
+ });
171
+ }
172
+
173
+ api.logger.info?.(`[restart-continuity] initialized (${defaults ? "defaults detected" : "no defaults file"})`);
174
+ }
175
+
176
+ async function runStartupCheck(api) {
177
+ const pluginConfig = api.pluginConfig || {};
178
+ const profile = getProfile(api);
179
+ const workspaceDir = getWorkspaceDir(api);
180
+ const stateFile = resolvePath(workspaceDir, pluginConfig.stateFile, "memory/restart-continuity-state.json");
181
+ const receiptFile = resolvePath(workspaceDir, pluginConfig.receiptFile, "memory/restart-continuity-receipt.json");
182
+ const logToDailyMemory = pluginConfig.logToDailyMemory !== false;
183
+ const notifyOnBootstrap = pluginConfig.notifyOnBootstrap !== false;
184
+ const { date, local } = getDatePartsForShanghai(new Date());
185
+ const resumers = Array.isArray(pluginConfig.resumers) ? pluginConfig.resumers : [];
186
+
187
+ const summary = {
188
+ version: 1,
189
+ timestamp: nowIso(),
190
+ timestampLocal: local,
191
+ checked: 0,
192
+ healthy: [],
193
+ resumed: [],
194
+ skipped: [],
195
+ errors: [],
196
+ results: [],
197
+ };
198
+
199
+ for (const resumer of resumers) {
200
+ if (!resumer || typeof resumer !== "object") continue;
201
+ if (resumer.enabled === false) {
202
+ summary.skipped.push(`${resumer.name || resumer.id || "unknown"} (disabled)`);
203
+ continue;
204
+ }
205
+ summary.checked += 1;
206
+ try {
207
+ if (resumer.kind === "cron-healthcheck") {
208
+ const result = await runCronHealthcheck(profile, resumer);
209
+ summary.results.push({ id: resumer.id, kind: resumer.kind, ...result });
210
+ if (result.status === "healthy") summary.healthy.push(result.detail);
211
+ else if (result.status === "resumed") summary.resumed.push(result.detail);
212
+ else if (result.status === "skipped") summary.skipped.push(result.detail);
213
+ else summary.errors.push(`${resumer.name || resumer.id}: unknown status`);
214
+ } else {
215
+ summary.errors.push(`${resumer.name || resumer.id}: unsupported kind ${String(resumer.kind)}`);
216
+ }
217
+ } catch (error) {
218
+ const message = error && error.message ? error.message : String(error);
219
+ summary.errors.push(`${resumer.name || resumer.id}: ${message}`);
220
+ summary.results.push({ id: resumer.id, kind: resumer.kind, status: "error", error: message });
221
+ }
222
+ }
223
+
224
+ summary.receipt = buildReceipt(summary);
225
+ await writeJson(stateFile, summary);
226
+
227
+ if (notifyOnBootstrap) {
228
+ await writeJson(receiptFile, {
229
+ createdAt: summary.timestamp,
230
+ source: "restart-continuity",
231
+ receipt: summary.receipt,
232
+ summary,
233
+ });
234
+ }
235
+
236
+ if (logToDailyMemory) {
237
+ const dailyPath = path.join(workspaceDir, "memory", `${date}.md`);
238
+ await appendText(dailyPath, buildDailyLog(summary));
239
+ }
240
+
241
+ api.logger.info?.(`[restart-continuity] ${summary.receipt}`);
242
+ }
243
+
244
+ const plugin = {
245
+ id: "restart-continuity",
246
+ name: "Restart Continuity",
247
+ description: "Gateway restart continuity plugin with resumable startup checks and receipt handoff.",
248
+ configSchema: {
249
+ parse(value) {
250
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
251
+ },
252
+ uiHints: {
253
+ profile: { label: "OpenClaw Profile" },
254
+ workspaceDir: { label: "Workspace Directory" },
255
+ stateFile: { label: "State File" },
256
+ receiptFile: { label: "Receipt File" },
257
+ defaultsFile: { label: "Defaults File" },
258
+ markerFile: { label: "Install Marker File" },
259
+ logToDailyMemory: { label: "Append summary to daily memory" },
260
+ notifyOnBootstrap: { label: "Inject startup receipt into next bootstrap reply" },
261
+ resumers: { label: "Resumable task definitions" },
262
+ },
263
+ },
264
+ register(api) {
265
+ api.registerService({
266
+ id: "restart-continuity-service",
267
+ start: async () => {
268
+ try {
269
+ await ensureConfigInitialized(api);
270
+ await runStartupCheck(api);
271
+ } catch (err) {
272
+ api.logger.warn?.(`[restart-continuity] service start failed: ${err instanceof Error ? err.message : String(err)}`);
273
+ }
274
+ },
275
+ stop: async () => {},
276
+ });
277
+
278
+ api.registerHook(
279
+ "agent:bootstrap",
280
+ async (event) => {
281
+ const pluginConfig = api.pluginConfig || {};
282
+ if (pluginConfig.notifyOnBootstrap === false) return;
283
+ const ctx = event && event.context ? event.context : {};
284
+ const bootstrapFiles = Array.isArray(ctx.bootstrapFiles) ? ctx.bootstrapFiles : null;
285
+ if (!bootstrapFiles) return;
286
+
287
+ const workspaceDir = getWorkspaceDir(api);
288
+ const receiptFile = resolvePath(workspaceDir, pluginConfig.receiptFile, "memory/restart-continuity-receipt.json");
289
+ const pending = await readJsonSafe(receiptFile, null);
290
+ if (!pending || !pending.receipt) return;
291
+
292
+ const content = [
293
+ "## Restart Continuity Receipt",
294
+ "",
295
+ pending.receipt,
296
+ "",
297
+ "Instruction: In your next reply to the user, include this startup receipt concisely (1-2 lines) before normal content. Do this once only.",
298
+ ].join("\n");
299
+
300
+ bootstrapFiles.push({ path: "RESTART_CONTINUITY_RECEIPT.md", content, virtual: true });
301
+ try {
302
+ await fs.unlink(receiptFile);
303
+ } catch {}
304
+ },
305
+ {
306
+ name: "restart-continuity.agent-bootstrap",
307
+ description: "Injects the pending restart continuity receipt into the next bootstrap reply.",
308
+ },
309
+ );
310
+ },
311
+ };
312
+
313
+ export default plugin;
@@ -0,0 +1,54 @@
1
+ {
2
+ "id": "restart-continuity",
3
+ "name": "Restart Continuity",
4
+ "description": "Gateway restart continuity plugin with resumable startup checks and receipt handoff.",
5
+ "version": "0.3.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "profile": { "type": "string", "default": "mh" },
11
+ "workspaceDir": { "type": "string", "default": "" },
12
+ "stateFile": { "type": "string", "default": "memory/restart-continuity-state.json" },
13
+ "receiptFile": { "type": "string", "default": "memory/restart-continuity-receipt.json" },
14
+ "defaultsFile": { "type": "string", "default": "plugins/restart-continuity.defaults.json" },
15
+ "markerFile": { "type": "string", "default": "memory/restart-continuity-installed.json" },
16
+ "logToDailyMemory": { "type": "boolean", "default": true },
17
+ "notifyOnBootstrap": { "type": "boolean", "default": true },
18
+ "resumers": {
19
+ "type": "array",
20
+ "items": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "id": { "type": "string" },
25
+ "kind": { "type": "string", "enum": ["cron-healthcheck"] },
26
+ "name": { "type": "string" },
27
+ "enabled": { "type": "boolean", "default": true },
28
+ "autoResume": { "type": "boolean", "default": true },
29
+ "jobId": { "type": "string" },
30
+ "successStatuses": {
31
+ "type": "array",
32
+ "items": { "type": "string" },
33
+ "default": ["ok"]
34
+ },
35
+ "runTimeoutMs": { "type": "integer", "minimum": 1000, "default": 120000 }
36
+ },
37
+ "required": ["id", "kind", "jobId"]
38
+ },
39
+ "default": []
40
+ }
41
+ }
42
+ },
43
+ "uiHints": {
44
+ "profile": { "label": "OpenClaw Profile" },
45
+ "workspaceDir": { "label": "Workspace Directory" },
46
+ "stateFile": { "label": "State File" },
47
+ "receiptFile": { "label": "Receipt File" },
48
+ "defaultsFile": { "label": "Defaults File" },
49
+ "markerFile": { "label": "Install Marker File" },
50
+ "logToDailyMemory": { "label": "Append summary to daily memory" },
51
+ "notifyOnBootstrap": { "label": "Inject startup receipt into next bootstrap reply" },
52
+ "resumers": { "label": "Resumable task definitions" }
53
+ }
54
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@ahyi/restart-continuity",
3
+ "version": "0.3.1",
4
+ "description": "OpenClaw restart continuity plugin with resumable startup checks and receipt handoff",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "openclaw",
12
+ "plugin",
13
+ "restart",
14
+ "continuity",
15
+ "gateway"
16
+ ],
17
+ "files": [
18
+ "index.ts",
19
+ "openclaw.plugin.json",
20
+ "README.md"
21
+ ],
22
+ "openclaw": {
23
+ "extensions": [
24
+ "./index.ts"
25
+ ]
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ }
30
+ }