@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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/index.js +130 -5
  3. 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.8.2" };
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) { saveAtomic(WAKE.state, JSON.stringify(st)); return 0; }
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.8.2",
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": [