@opticell/workflow-companion 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/index.js +130 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,6 +181,7 @@ npx -y @opticell/workflow-companion wake-on
|
|
|
181
181
|
| `workflow-companion setup` | Signs this computer in. Takes `--web-token` to skip the prompt. |
|
|
182
182
|
| `workflow-companion status` | Which device answers, how fresh the snapshot is, any receipts that arrived late. |
|
|
183
183
|
| `workflow-companion doctor` | Checks everything on this computer and says how to fix what's wrong. Start here when something isn't working. |
|
|
184
|
+
| `workflow-companion canary` | Runs the daily self-test now. `canary on\|off`, `canary at HH:MM`. |
|
|
184
185
|
| `workflow-companion keepalive` | Renews the sign-in. The scheduled job runs this. |
|
|
185
186
|
| `workflow-companion update` | Updates the scheduled jobs' copy to the latest release now, and shows how to keep Claude Code on `@latest`. |
|
|
186
187
|
| `workflow-companion auto-update on\|off` | Whether the scheduled jobs update themselves (on by default). |
|
|
@@ -231,6 +232,13 @@ move to `Filed/<date>`. `--assign` puts them in this assistant's queue.
|
|
|
231
232
|
changes, an approval is answered, a session needs attention, or the iCloud
|
|
232
233
|
sign-in has ended (with the command that fixes it).
|
|
233
234
|
|
|
235
|
+
**Daily self-test (1.9).** With waking on, once a day (07:30, or
|
|
236
|
+
`canary at HH:MM`) the companion proves the whole chain still works: WorkFlow
|
|
237
|
+
applies its commands, Claude Code runs and can write, and the change comes
|
|
238
|
+
back. A pass quietly updates its "⚙︎ <name> daily self-test" task; a failure
|
|
239
|
+
comments there, which reaches your phone, naming the step and the fix.
|
|
240
|
+
`workflow-companion canary` runs it now; `canary off` stops it.
|
|
241
|
+
|
|
234
242
|
**Session reports.** After each session that changed something, a short
|
|
235
243
|
report is attached to its task: what it was asked, what it changed, any
|
|
236
244
|
approved commands it ran, and its last words.
|
package/index.js
CHANGED
|
@@ -27,7 +27,7 @@ const path = require("path");
|
|
|
27
27
|
const os = require("os");
|
|
28
28
|
|
|
29
29
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
30
|
-
const SERVER_INFO = { name: "workflow", version: "1.
|
|
30
|
+
const SERVER_INFO = { name: "workflow", version: "1.9.0" };
|
|
31
31
|
// Who the agent is in WorkFlow: the assignee its queue follows, the @name
|
|
32
32
|
// that calls it, the signature on what it writes. On the Mac it's Claude.
|
|
33
33
|
// A companion goes by the name chosen at setup (default "Companion"), so a
|
|
@@ -1599,7 +1599,7 @@ function killTree(pid) {
|
|
|
1599
1599
|
else { try { process.kill(-pid, "SIGTERM"); } catch (_) { try { process.kill(pid, "SIGTERM"); } catch (_) {} } }
|
|
1600
1600
|
}
|
|
1601
1601
|
|
|
1602
|
-
function runSession(cfg, prompt) {
|
|
1602
|
+
function runSession(cfg, prompt, limitMs) {
|
|
1603
1603
|
const { spawn } = require("child_process");
|
|
1604
1604
|
const bin = cfg.claudeBin || "claude";
|
|
1605
1605
|
const args = ["-p", "--allowedTools", cfg.wakeAllowedTools || "mcp__workflow"];
|
|
@@ -1609,7 +1609,7 @@ function runSession(cfg, prompt) {
|
|
|
1609
1609
|
const shell = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
|
|
1610
1610
|
const cwd = cfg.wakeWorkDir && fs.existsSync(cfg.wakeWorkDir) ? cfg.wakeWorkDir : os.homedir();
|
|
1611
1611
|
// WF_WAKE_MAX_SECONDS is for the tests.
|
|
1612
|
-
const limit = process.env.WF_WAKE_MAX_SECONDS ? Number(process.env.WF_WAKE_MAX_SECONDS) * 1000 : (cfg.wakeMaxMinutes || WAKE.maxMinutes) * 60000;
|
|
1612
|
+
const limit = process.env.WF_WAKE_MAX_SECONDS ? Number(process.env.WF_WAKE_MAX_SECONDS) * 1000 : limitMs || (cfg.wakeMaxMinutes || WAKE.maxMinutes) * 60000;
|
|
1613
1613
|
return new Promise(resolve => {
|
|
1614
1614
|
let out = "", timedOut = false, child;
|
|
1615
1615
|
try {
|
|
@@ -1675,7 +1675,12 @@ async function wake() {
|
|
|
1675
1675
|
const live = new Set(items.map(i => i.key));
|
|
1676
1676
|
for (const k of Object.keys(st.seen)) if (!live.has(k)) { delete st.seen[k]; delete st.attempts[k]; }
|
|
1677
1677
|
const fresh = items.filter(i => st.seen[i.key] !== i.sig);
|
|
1678
|
-
if (!fresh.length) {
|
|
1678
|
+
if (!fresh.length) {
|
|
1679
|
+
saveAtomic(WAKE.state, JSON.stringify(st));
|
|
1680
|
+
// 1.9: the daily self-test, when nothing else is waiting.
|
|
1681
|
+
if (canaryDue(readJSON(CP.config, cfg))) await runCanary(readJSON(CP.config, cfg));
|
|
1682
|
+
return 0;
|
|
1683
|
+
}
|
|
1679
1684
|
|
|
1680
1685
|
// Marked handled BEFORE the session: re-running half-done work unattended
|
|
1681
1686
|
// is worse than dropping it. A session that wrote nothing at all is the
|
|
@@ -2266,6 +2271,118 @@ async function inboxCLI(on) {
|
|
|
2266
2271
|
return 0;
|
|
2267
2272
|
}
|
|
2268
2273
|
|
|
2274
|
+
// ------------------------------------------------------------------ canary (1.9)
|
|
2275
|
+
//
|
|
2276
|
+
// A daily self-test of the whole chain, so a break shows up in the morning
|
|
2277
|
+
// rather than when something needs doing. Once a day (07:30 by default, only
|
|
2278
|
+
// with waking on), in the scheduled job:
|
|
2279
|
+
// 1. doctor's checks
|
|
2280
|
+
// 2. its own "⚙︎ <name> daily self-test" task exists: creating it proves
|
|
2281
|
+
// WorkFlow applies this companion's commands
|
|
2282
|
+
// 3. a tiny real Claude session writes a one-off code into that task's
|
|
2283
|
+
// status line: Claude Code is signed in, has the tools, and can write
|
|
2284
|
+
// 4. the code comes back in WorkFlow's published snapshot: the round trip
|
|
2285
|
+
// A pass only updates the status line (no comment, so no notification). A
|
|
2286
|
+
// failure posts one comment naming the step and the fix, which reaches the
|
|
2287
|
+
// phone; a recovery says so too.
|
|
2288
|
+
|
|
2289
|
+
const CANARY_DEFAULT_AT = "07:30";
|
|
2290
|
+
const localDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
2291
|
+
function canaryTitle() { return `⚙︎ ${AGENT.name} daily self-test`; }
|
|
2292
|
+
|
|
2293
|
+
function canaryDue(cfg, now = new Date()) {
|
|
2294
|
+
if (!cfg || !cfg.wakeEnabled || cfg.canary === false) return false;
|
|
2295
|
+
if (cfg.canaryLastDay === localDay(now)) return false;
|
|
2296
|
+
const [h, m] = String(cfg.canaryAt || CANARY_DEFAULT_AT).split(":").map(Number);
|
|
2297
|
+
return now.getHours() * 60 + now.getMinutes() >= (h || 0) * 60 + (m || 0);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
async function runCanary(cfg) {
|
|
2301
|
+
const started = Date.now();
|
|
2302
|
+
const host = os.hostname();
|
|
2303
|
+
updateConfig(c => { c.canaryLastDay = localDay(); }); // once a day, even if this dies part-way
|
|
2304
|
+
const fail = (step, detail, fix) => ({ ok: false, step, detail, fix });
|
|
2305
|
+
let result, taskID = null;
|
|
2306
|
+
try {
|
|
2307
|
+
result = await (async () => {
|
|
2308
|
+
// Found first, so any failure below can be reported on it.
|
|
2309
|
+
const existing = (cloudSnap && cloudSnap.tasks || []).find(t => t.title === canaryTitle());
|
|
2310
|
+
if (existing) taskID = existing.id;
|
|
2311
|
+
const health = await doctorReport({ fromTool: true, forCanary: true });
|
|
2312
|
+
if (health.problems) {
|
|
2313
|
+
const first = health.text.split("\n").findIndex(l => l.startsWith(" ✗"));
|
|
2314
|
+
const lines = health.text.split("\n");
|
|
2315
|
+
return fail("health check", lines[first].replace(/^\s*✗\s*/, ""), (lines[first + 1] || "").replace(/^\s*fix:\s*/, "") || "run: npx -y @opticell/workflow-companion@latest doctor");
|
|
2316
|
+
}
|
|
2317
|
+
if (!taskID) {
|
|
2318
|
+
const r = await send("create_task", { title: canaryTitle(), priority: "Low", hasDueDate: false,
|
|
2319
|
+
notes: `${AGENT.name} on ${host} checks itself here once a day: WorkFlow, iCloud and Claude Code, end to end. A pass updates the status line; a failure comments, so you hear about it. Leave this task be.` }, 60000);
|
|
2320
|
+
if (r.status !== "applied" || !r.taskID) return fail("WorkFlow applying commands", "WorkFlow didn't apply a command within a minute", "open WorkFlow on the Mac or iPhone that answers the companion (doctor shows which)");
|
|
2321
|
+
taskID = r.taskID;
|
|
2322
|
+
}
|
|
2323
|
+
const nonce = require("crypto").randomBytes(3).toString("hex");
|
|
2324
|
+
const t0 = Date.now();
|
|
2325
|
+
const r = await runSession(cfg, `This is ${AGENT.name}'s scheduled self-test on ${host}. Do exactly one thing: call workflow_set_status_line with task "${taskID}" and text "self-test ${nonce}". Don't comment, don't read or change anything else, then stop.`, 5 * 60000);
|
|
2326
|
+
if (r.timedOut) return fail("Claude session", "it didn't finish within 5 minutes", `a prompt may be waiting on ${host}; check Claude Code there`);
|
|
2327
|
+
if (r.code !== 0) return fail("Claude session", attentionReason(r.out, `Claude Code exited with ${r.code}`), "run claude on that computer and check it's signed in and has usage left");
|
|
2328
|
+
const answered = Math.round((Date.now() - t0) / 1000);
|
|
2329
|
+
for (let i = 0; i < 12; i++) {
|
|
2330
|
+
await cloud.load(true);
|
|
2331
|
+
const t = (cloudSnap.tasks || []).find(x => x.id === taskID);
|
|
2332
|
+
if (t && String(t.pinnedNote || "").includes(nonce)) return { ok: true, answered };
|
|
2333
|
+
await delay(15000);
|
|
2334
|
+
}
|
|
2335
|
+
return fail("the round trip", "Claude's change didn't come back through WorkFlow within 3 minutes", "WorkFlow on the answering device may be asleep or closed; run doctor");
|
|
2336
|
+
})();
|
|
2337
|
+
} catch (e) {
|
|
2338
|
+
result = fail(e instanceof SignInNeeded ? "iCloud sign-in" : "the self-test", String(e.message).split("\n")[0],
|
|
2339
|
+
e instanceof SignInNeeded ? "npx -y @opticell/workflow-companion@latest setup" : "run doctor on that computer");
|
|
2340
|
+
}
|
|
2341
|
+
const when = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
2342
|
+
const wasFailing = cfg.canaryLastOk === false;
|
|
2343
|
+
updateConfig(c => { c.canaryLastOk = result.ok; c.canaryLast = Object.assign({ at: new Date().toISOString() }, result); });
|
|
2344
|
+
logWake(`self-test: ${result.ok ? `passed (Claude answered in ${result.answered} s)` : `FAILED at ${result.step}: ${result.detail}`}`);
|
|
2345
|
+
try {
|
|
2346
|
+
if (taskID) {
|
|
2347
|
+
await send("update_task", { taskID, pinnedNote: result.ok ? `✓ Self-test passed ${when} on ${host}: Claude answered in ${result.answered} s.` : `✗ Self-test failed ${when} on ${host}: ${result.step}.` });
|
|
2348
|
+
if (!result.ok) await send("add_comment", { taskID, text: `✗ My daily self-test failed on ${host} (${result.step}): ${result.detail}.\nFix: ${result.fix}.` });
|
|
2349
|
+
else if (wasFailing) await send("add_comment", { taskID, text: `✓ My daily self-test passes again on ${host}.` });
|
|
2350
|
+
}
|
|
2351
|
+
} catch (_) { /* reported on the PC below */ }
|
|
2352
|
+
if (!result.ok) {
|
|
2353
|
+
notify(`${AGENT.name}: self-test failed`, `${result.step}: ${result.detail}. Fix: ${result.fix}`);
|
|
2354
|
+
await reportActivity("attention", { reason: `self-test failed (${result.step}): ${result.detail}`.slice(0, 150) }).catch(() => {});
|
|
2355
|
+
}
|
|
2356
|
+
return result;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
async function canaryCLI() {
|
|
2360
|
+
const sub = argv[argv.indexOf("canary") + 1];
|
|
2361
|
+
const cfg = readJSON(CP.config, null);
|
|
2362
|
+
if (!cfg) { console.error(signInHelp("Not signed in yet.")); return 2; }
|
|
2363
|
+
if (sub === "off" || sub === "on") {
|
|
2364
|
+
updateConfig(c => { c.canary = sub === "on"; });
|
|
2365
|
+
console.log(sub === "on" ? `The daily self-test is on, at ${cfg.canaryAt || CANARY_DEFAULT_AT} (it needs waking on).` : "The daily self-test is off.");
|
|
2366
|
+
return 0;
|
|
2367
|
+
}
|
|
2368
|
+
if (sub === "at") {
|
|
2369
|
+
const at = argv[argv.indexOf("at") + 1] || "";
|
|
2370
|
+
if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(at)) { console.error("Give a time as HH:MM, e.g. canary at 07:30"); return 2; }
|
|
2371
|
+
updateConfig(c => { c.canaryAt = at; });
|
|
2372
|
+
console.log(`The daily self-test runs at ${at}.`);
|
|
2373
|
+
return 0;
|
|
2374
|
+
}
|
|
2375
|
+
if (!cfg.claudeBin) { console.error("The self-test runs Claude Code, so waking has to be set up first: workflow-companion wake-on"); return 2; }
|
|
2376
|
+
if (!takeWakeLock(cfg.wakeMaxMinutes || WAKE.maxMinutes)) { console.error("A session is running; try again when it has finished."); return 1; }
|
|
2377
|
+
try {
|
|
2378
|
+
await cloud.load(true);
|
|
2379
|
+
console.log("Running the self-test (up to about 8 minutes)…");
|
|
2380
|
+
const r = await runCanary(Object.assign({}, cfg, readJSON(CP.config, {})));
|
|
2381
|
+
console.log(r.ok ? `✓ Passed: Claude answered in ${r.answered} s, and its change came back through WorkFlow.` : `✗ Failed at ${r.step}: ${r.detail}\n fix: ${r.fix}`);
|
|
2382
|
+
return r.ok ? 0 : 1;
|
|
2383
|
+
} finally { try { fs.unlinkSync(WAKE.lock); } catch (_) {} }
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2269
2386
|
// ------------------------------------------------------------------ doctor (1.8.1)
|
|
2270
2387
|
//
|
|
2271
2388
|
// Checks the whole chain on this computer in one go and says how to fix
|
|
@@ -2274,7 +2391,7 @@ async function inboxCLI(on) {
|
|
|
2274
2391
|
// Claude Code, and what the last session did. `fromTool` skips the checks
|
|
2275
2392
|
// that would be odd from inside a Claude session (asking Claude Code about
|
|
2276
2393
|
// itself).
|
|
2277
|
-
async function doctorReport({ fromTool = false } = {}) {
|
|
2394
|
+
async function doctorReport({ fromTool = false, forCanary = false } = {}) {
|
|
2278
2395
|
const npx = "npx -y @opticell/workflow-companion@latest";
|
|
2279
2396
|
const lines = [`WorkFlow companion ${SERVER_INFO.version} on ${os.hostname()}, as "${AGENT.name}"`, ""];
|
|
2280
2397
|
let problems = 0, warnings = 0;
|
|
@@ -2374,6 +2491,12 @@ async function doctorReport({ fromTool = false } = {}) {
|
|
|
2374
2491
|
else ok("Claude Code has the companion, on @latest");
|
|
2375
2492
|
}
|
|
2376
2493
|
|
|
2494
|
+
if (!forCanary && cfg.wakeEnabled && cfg.canary !== false) {
|
|
2495
|
+
const last = cfg.canaryLast;
|
|
2496
|
+
if (!last) ok(`daily self-test on, at ${cfg.canaryAt || CANARY_DEFAULT_AT}; it hasn't run yet`);
|
|
2497
|
+
else if (last.ok) ok(`last self-test passed ${ago(Date.parse(last.at))}`);
|
|
2498
|
+
else bad(`last self-test failed ${ago(Date.parse(last.at))} (${last.step}): ${last.detail}`, last.fix);
|
|
2499
|
+
}
|
|
2377
2500
|
const failing = loadMonitors().filter(m => m.state && !m.state.ok);
|
|
2378
2501
|
if (loadMonitors().length) failing.length ? warn(`monitors failing: ${failing.map(m => `${m.name} (${m.state.detail})`).join(", ")}`) : ok(`${loadMonitors().length} monitor(s), all OK`);
|
|
2379
2502
|
const pending = loadApprovals().filter(a => a.status === "pending");
|
|
@@ -2609,6 +2732,7 @@ const HELP = `WorkFlow companion ${SERVER_INFO.version}
|
|
|
2609
2732
|
workflow-companion setup sign this computer in to iCloud for WorkFlow (--name, --takeover, --no-browser)
|
|
2610
2733
|
workflow-companion status which device answers, how fresh is the snapshot
|
|
2611
2734
|
workflow-companion doctor check everything on this computer and say how to fix what's wrong
|
|
2735
|
+
workflow-companion canary run the daily self-test now (also: canary on|off, canary at HH:MM)
|
|
2612
2736
|
workflow-companion keepalive renew the sign-in (what the scheduled job runs)
|
|
2613
2737
|
workflow-companion update update the scheduled jobs' copy now, and say how to keep Claude Code current
|
|
2614
2738
|
workflow-companion auto-update on|off whether the scheduled jobs update themselves (on by default)
|
|
@@ -2639,6 +2763,7 @@ if (require.main !== module) {
|
|
|
2639
2763
|
if (argv.includes("--print-config")) printConfig();
|
|
2640
2764
|
else if (argv.includes("--help") || verb === "help") process.stdout.write(HELP);
|
|
2641
2765
|
else if (verb === "setup") setup().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
2766
|
+
else if (verb === "canary") canaryCLI().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
2642
2767
|
else if (verb === "doctor") doctorReport().then(r => { console.log(r.text); process.exitCode = r.problems ? 1 : 0; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
2643
2768
|
else if (verb === "status") cloudStateText().then(t => console.log(t), e => { console.error(e.message || e); process.exitCode = 1; });
|
|
2644
2769
|
else if (verb === "keepalive") keepAlive().then(c => { process.exitCode = c; });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opticell/workflow-companion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"mcpName": "io.github.TeamDzX/workflow-companion",
|
|
5
5
|
"description": "Lets Claude on Windows or Linux work in your WorkFlow tasks, notes and projects, through WorkFlow for Mac and your own iCloud. An MCP server.",
|
|
6
6
|
"keywords": [
|