@opticell/workflow-companion 1.7.0 → 1.8.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 +20 -0
  2. package/index.js +593 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,15 @@ away in the app switcher. With both switched on, the Mac answers while it's
26
26
  awake and the iPhone stands by. Every command is applied exactly once, however
27
27
  many devices are on.
28
28
 
29
+ ## Install
30
+
31
+ **Windows** (PowerShell): `irm https://raw.githubusercontent.com/TeamDzX/workflow-companion/main/install.ps1 | iex`
32
+
33
+ **Mac or Linux:** `curl -fsSL https://raw.githubusercontent.com/TeamDzX/workflow-companion/main/install.sh | sh`
34
+
35
+ It installs Node.js if needed, signs this computer in, adds the companion to
36
+ Claude Code and offers to let it start work on its own. By hand:
37
+
29
38
  ## Set up
30
39
 
31
40
  1. In WorkFlow on an iPhone or iPad: **Settings → Windows Companion → Answer
@@ -157,6 +166,17 @@ npx -y @opticell/workflow-companion wake-on
157
166
  | `workflow-companion wake` | One check of the queue. The scheduled job runs this. |
158
167
  | `workflow-companion signout` | Deletes the sign-in, cached data and both scheduled jobs. |
159
168
 
169
+ ## On this computer, by itself (1.8)
170
+
171
+ In the same 2-minute scheduled job, without using Claude: **monitors**
172
+ (`monitor add --http/--port/--disk/--process/--file/--command`, posting
173
+ changes on a task and optionally waking a session), **approvals** (an
174
+ assistant asks to run one exact command and it runs once you reply
175
+ "approve CODE"; off until `approvals on`), an **inbox folder** (`inbox-on`:
176
+ dropped files become tasks), **notifications** on the computer, and a
177
+ **session report** attached to the task after each session. Details:
178
+ <https://github.com/TeamDzX/workflow-companion#on-this-computer-by-itself-18>
179
+
160
180
  ## Worth knowing
161
181
 
162
182
  - **Something has to be answering.** A command sent while nothing is waits in
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.7.0" };
30
+ const SERVER_INFO = { name: "workflow", version: "1.8.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
@@ -426,6 +426,7 @@ const cloud = (() => {
426
426
  if (err) throw err;
427
427
  setOwn(own().concat({ name, op: command.op, at: new Date().toISOString() }));
428
428
  countSent();
429
+ logOp(command);
429
430
  // A push usually brings the Mac within seconds; failing that it looks
430
431
  // every 15 s. Check often at first, then wait out a little over two rounds.
431
432
  const schedule = quick ? [2000, 3000, 5000] : [2000, 2000, 2000, 2000, 2000, 5000, 5000, 5000, 5000, 5000, 5000];
@@ -1090,6 +1091,83 @@ const TOOLS = [
1090
1091
  inputSchema: { type: "object", properties: {} },
1091
1092
  handler: () => houseRules(snapshot()) || "No house rules set.",
1092
1093
  },
1094
+ {
1095
+ name: "workflow_monitor",
1096
+ description: "Checks that run on this computer every few minutes without using Claude, and wake you only when something changes: a website (http), a port (host:port), free disk space (disk, min_free_gb), a running process, or a file changing. Changes are posted on the task you name. action: add | list | remove. Command monitors can only be added by the person at this computer.",
1097
+ inputSchema: { type: "object", properties: {
1098
+ action: { type: "string", enum: ["add", "list", "remove"] }, name: { type: "string" },
1099
+ type: { type: "string", enum: ["http", "port", "disk", "process", "file"] }, target: { type: "string", description: "URL, host:port, folder, process name or file" },
1100
+ task: { type: "string", description: "where changes are posted" }, every: { type: "number", description: "minutes, 2 or more (default 5)" },
1101
+ wake: { type: "boolean", description: "start a session when it changes" }, min_free_gb: { type: "number" }, expect_text: { type: "string", description: "http: text the page must contain" } },
1102
+ required: ["action"] },
1103
+ handler: async a => {
1104
+ if (a.action === "list") { const l = loadMonitors(); return l.length ? l.map(monitorLine).join("\n") : "No monitors on this computer."; }
1105
+ if (a.action === "remove") { removeMonitor(a.name); return `Removed “${a.name}”.`; }
1106
+ const t = a.task ? findTask(a.task) : null;
1107
+ const m = addMonitor({ name: a.name, type: a.type, target: a.target, every: a.every, wake: a.wake, task: t ? t.id : "", taskTitle: t ? t.title : "",
1108
+ minFreeGB: a.min_free_gb, expectText: a.expect_text }, "assistant");
1109
+ const r = await checkMonitor(m);
1110
+ return `Monitor “${m.name}” added on ${os.hostname()}: ${r.ok ? "OK" : "FAILED"} now (${r.detail}). Checked every ${m.every} min; schedule: ${ensureTick()}.`;
1111
+ },
1112
+ },
1113
+ {
1114
+ name: "workflow_request_approval",
1115
+ description: "Ask the user to approve one command before it runs on this computer (restart a service, install an update, delete old backups). Posts the exact command on the task with a code; when the user replies \"approve CODE\" you're woken, and workflow_run_approved runs exactly that command. Only the user's reply counts. Off unless the user has switched approvals on here.",
1116
+ inputSchema: { type: "object", properties: { task: { type: "string" }, action: { type: "string", description: "what it does, in plain words" }, command: { type: "string", description: "the exact shell command" }, cwd: { type: "string" } }, required: ["task", "action", "command"] },
1117
+ handler: async a => {
1118
+ const cfg = readJSON(CP.config, {}) || {};
1119
+ if (!CLOUD || !cfg.approvalsEnabled) throw new Error("Approvals are off on this computer. Only the person here can switch them on, with: workflow-companion approvals on");
1120
+ const t = findTask(a.task);
1121
+ const shared = new Set((snapshot().projects || []).filter(p => p.isShared).map(p => p.name));
1122
+ if (t.project && shared.has(t.project)) throw new Error("Not on a task in a shared project: someone else could answer it.");
1123
+ const command = String(a.command).trim();
1124
+ if (!command || command.length > 1000) throw new Error("Give the exact command (up to 1000 characters).");
1125
+ const code = approvalCode();
1126
+ const now = Date.now();
1127
+ const r = await send("add_comment", { taskID: t.id, text: `🔐 Approval needed on ${os.hostname()}: ${String(a.action).slice(0, 200)}\nCommand: ${command}${a.cwd ? `\nIn: ${a.cwd}` : ""}\nReply “approve ${code}” to run it, or “decline ${code}”. Expires in 24 h.` });
1128
+ if (r.status !== "applied" && r.status !== "pending") return receiptText(r);
1129
+ const list = loadApprovals();
1130
+ list.push({ code, taskID: t.id, taskTitle: t.title, action: String(a.action).slice(0, 200), command, cwd: a.cwd || "", requestedAt: new Date(now).toISOString(), expiresAt: new Date(now + APPROVAL_TTL_MS).toISOString(), status: "pending" });
1131
+ saveApprovals(list);
1132
+ ensureTick();
1133
+ return `Asked for approval ${code}. You'll be woken when the user answers; nothing runs before that. Stop here for now.`;
1134
+ },
1135
+ },
1136
+ {
1137
+ name: "workflow_check_approval",
1138
+ description: "Whether the user has approved or declined an approval request (by its code).",
1139
+ inputSchema: { type: "object", properties: { code: { type: "string" } }, required: ["code"] },
1140
+ handler: a => {
1141
+ refreshApprovals(snapshot());
1142
+ const x = findApproval(a.code);
1143
+ const when = x.decidedAt ? ` by ${x.decidedBy}, ${local(x.decidedAt)}` : "";
1144
+ return `${x.code}: ${x.status}${when}\n ${x.action}\n command: ${x.command}${x.ranAt ? `\n ran ${local(x.ranAt)}: exit ${x.result.exit}` : ""}`;
1145
+ },
1146
+ },
1147
+ {
1148
+ name: "workflow_run_approved",
1149
+ description: "Run a command the user has approved, exactly as it was when they approved it. Once only. Returns its exit code and output; report the result in the task's thread.",
1150
+ inputSchema: { type: "object", properties: { code: { type: "string" } }, required: ["code"] },
1151
+ handler: a => {
1152
+ const cfg = readJSON(CP.config, {}) || {};
1153
+ if (!cfg.approvalsEnabled) throw new Error("Approvals are off on this computer.");
1154
+ refreshApprovals(snapshot());
1155
+ const x = findApproval(a.code);
1156
+ if (x.ranAt) throw new Error(`${x.code} already ran (${local(x.ranAt)}). Ask again for another run.`);
1157
+ if (x.status !== "approved") throw new Error(`${x.code} is ${x.status}, not approved.`);
1158
+ if (Date.now() > Date.parse(x.expiresAt)) throw new Error(`${x.code} has expired.`);
1159
+ // Marked before running: a crash mid-run must not allow a second run.
1160
+ const list = loadApprovals();
1161
+ const row = list.find(y => y.code === x.code);
1162
+ row.ranAt = new Date().toISOString();
1163
+ saveApprovals(list);
1164
+ const result = runApproved(x);
1165
+ row.result = { exit: result.exit, seconds: result.seconds };
1166
+ saveApprovals(list);
1167
+ logWake(`approval ${x.code}: ran "${x.command}" → exit ${result.exit}`);
1168
+ return `Ran ${x.code} on ${os.hostname()} (${result.seconds} s): exit ${result.exit}\n${result.output || "(no output)"}`;
1169
+ },
1170
+ },
1093
1171
  {
1094
1172
  name: "workflow_ping",
1095
1173
  description: "Confirm the app is watching the bridge (round-trips one command).",
@@ -1174,6 +1252,12 @@ async function cloudStateText() {
1174
1252
  if (lastKeepAlive) lines.push(`keep-alive last run ${lastKeepAlive}`);
1175
1253
  lines.push(`waking ${cfg.wakeEnabled ? `on — Claude Code starts by itself when @${AGENT.name} work arrives (log ${WAKE.log})` : "off — work waits until Claude here is asked (turn on: workflow-companion wake-on)"}`);
1176
1254
  lines.push(`version ${await versionLine()}`);
1255
+ const mons = loadMonitors();
1256
+ if (mons.length) lines.push(`monitors ${mons.length}${mons.some(m => m.state && !m.state.ok) ? `, FAILING: ${mons.filter(m => m.state && !m.state.ok).map(m => m.name).join(", ")}` : ", all OK"} (workflow-companion monitor list)`);
1257
+ const pending = loadApprovals().filter(a => a.status === "pending");
1258
+ lines.push(`approvals ${cfg.approvalsEnabled ? `on${pending.length ? `, ${pending.length} waiting: ${pending.map(a => a.code).join(", ")}` : ""}` : "off (workflow-companion approvals on)"}`);
1259
+ if (cfg.inboxFolder) lines.push(`inbox ${cfg.inboxFolder}`);
1260
+ lines.push(`notices notifications ${cfg.notifications === false ? "off" : "on"}, session reports ${cfg.reports === false ? "off" : "on"}`);
1177
1261
  lines.push(`name ${AGENT.name} — assign tasks to "${AGENT.name}", or write @${AGENT.name} in a comment or note`);
1178
1262
  try {
1179
1263
  await cloud.load(true);
@@ -1319,10 +1403,18 @@ async function keepAlive() {
1319
1403
  return 0;
1320
1404
  } catch (e) {
1321
1405
  logKeepAlive(`${at} ${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "failed"}: ${String(e.message).split("\n")[0]}`);
1406
+ if (e instanceof SignInNeeded) signInEnded();
1322
1407
  return 1;
1323
1408
  }
1324
1409
  }
1325
1410
 
1411
+ // The iCloud sign-in has ended (#5). With no sign-in the companion can't
1412
+ // reach iCloud to tell the phone, so it says so on this computer; WorkFlow
1413
+ // shows the companion going quiet in Who's Bridged.
1414
+ function signInEnded() {
1415
+ notify(`WorkFlow companion (${AGENT.name})`, `The iCloud sign-in on ${os.hostname()} has ended, so ${AGENT.name} can't see new work. Run: npx -y @opticell/workflow-companion@latest setup`, "signin-ended");
1416
+ }
1417
+
1326
1418
  function logKeepAlive(line) {
1327
1419
  let lines = [];
1328
1420
  try { lines = fs.readFileSync(KEEPALIVE_LOG, "utf8").trim().split("\n"); } catch (_) {}
@@ -1470,7 +1562,7 @@ Do the work, then report back in the task's own thread so it reaches the user's
1470
1562
  workflow_complete_task only when the task is plainly finished
1471
1563
  For a [note], answer with workflow_reply_note, or workflow_convert_note if it describes work worth tracking.
1472
1564
 
1473
- Only @mention another assistant (such as @Claude) when you need it to act: an @mention wakes it. A thank-you or an FYI must not include one. When the work itself should move to another assistant or a person, use workflow_hand_off. To check on something later ("tell me when it's back up"), use workflow_watch rather than waiting: you'll be woken at that time, and a watch fires once, so re-arm it if you're still waiting.
1565
+ Only @mention another assistant (such as @Claude) when you need it to act: an @mention wakes it. A thank-you or an FYI must not include one. When the work itself should move to another assistant or a person, use workflow_hand_off. To check on something later ("tell me when it's back up"), use workflow_watch rather than waiting: you'll be woken at that time, and a watch fires once, so re-arm it if you're still waiting. To keep an eye on a website, port, disk or process, use workflow_monitor: it checks without you and wakes you only on a change. To run a command on this computer that you aren't allowed to run yourself, ask with workflow_request_approval and stop; you'll be woken when the user answers.
1474
1566
 
1475
1567
  How to write into a thread (it is read on a phone): one reply per request, about six short lines at most, leading with what changed or what you need. Anything longer goes in a note (workflow_create_note) or an attached file (workflow_attach_file), linked from the reply. Never paste logs or code into a comment. If you need something from the user, ask in the comment and leave it open.
1476
1568
 
@@ -1528,18 +1620,47 @@ async function wake() {
1528
1620
  if (!CLOUD) { console.error("wake is for the companion (iCloud mode). On a Mac, the app's own wake trigger does this: wf wake install."); return 2; }
1529
1621
  const cfg = readJSON(CP.config, null);
1530
1622
  if (!cfg) { console.error(signInHelp("Not signed in yet.")); return 2; }
1623
+ // 1.8: monitors, the inbox folder and approvals run on every tick, with
1624
+ // waking on or off, and never wait behind a running session.
1625
+ const extras = loadMonitors().length || inboxFolder(cfg) || loadApprovals().some(a => a.status === "pending");
1626
+ if (!cfg.wakeEnabled && !extras) return 0;
1627
+ if (extras) {
1628
+ try { await cloud.load(true); await reportPresence(); await tickExtras(); }
1629
+ catch (e) {
1630
+ logWake(`${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "iCloud failed"}: ${String(e.message).split("\n")[0]}`);
1631
+ if (e instanceof SignInNeeded) signInEnded();
1632
+ return 1;
1633
+ }
1634
+ }
1531
1635
  if (!cfg.wakeEnabled) return 0;
1532
1636
  const maxMinutes = cfg.wakeMaxMinutes || WAKE.maxMinutes;
1533
1637
  if (!takeWakeLock(maxMinutes)) return 0; // a session is already running
1534
1638
  try {
1535
- try { await cloud.load(true); }
1536
- catch (e) { logWake(`${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "iCloud failed"}: ${String(e.message).split("\n")[0]}`); return 1; }
1537
- await reportPresence();
1639
+ try { if (!extras) await cloud.load(true); }
1640
+ catch (e) {
1641
+ logWake(`${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "iCloud failed"}: ${String(e.message).split("\n")[0]}`);
1642
+ if (e instanceof SignInNeeded) signInEnded();
1643
+ return 1;
1644
+ }
1645
+ if (!extras) await reportPresence();
1538
1646
  if (!cloudSnap) return 0;
1539
1647
  if (blockingClash()) { logWake(`not waking: "${AGENT.name}" is also Claude on another computer`); return 0; }
1540
1648
  const st = readJSON(WAKE.state, null) || {};
1541
1649
  st.seen = st.seen || {}; st.attempts = st.attempts || {};
1542
1650
  const items = wakeItems(cloudSnap);
1651
+ // 1.8: an answered approval wakes the session that asked, once.
1652
+ for (const a of loadApprovals()) {
1653
+ if ((a.status === "approved" || a.status === "declined") && !a.wokeAt) {
1654
+ items.push({ key: `approval:${a.code}`, sig: String(a.decisionComment || a.decidedAt), kind: "task", id: a.taskID, title: a.taskTitle,
1655
+ project: "", why: `your approval request ${a.code} was ${a.status} by ${a.decidedBy}${a.status === "approved" ? "; run it with workflow_run_approved" : ""}` });
1656
+ }
1657
+ }
1658
+ // 1.8: a monitor that changed and is set to wake.
1659
+ const monitorWakes = readJSON(X.monitorWakes, []) || [];
1660
+ if (monitorWakes.length) {
1661
+ for (const w of monitorWakes) items.push({ key: w.key, sig: w.key, kind: "task", id: w.taskID, title: w.title, project: "", why: w.why });
1662
+ try { fs.unlinkSync(X.monitorWakes); } catch (_) {}
1663
+ }
1543
1664
  // Forget what has left the queue, so it can wake again if it comes back.
1544
1665
  const live = new Set(items.map(i => i.key));
1545
1666
  for (const k of Object.keys(st.seen)) if (!live.has(k)) { delete st.seen[k]; delete st.attempts[k]; }
@@ -1553,6 +1674,13 @@ async function wake() {
1553
1674
  for (const i of fresh) { prior[i.key] = st.seen[i.key]; st.seen[i.key] = i.sig; }
1554
1675
  saveAtomic(WAKE.state, JSON.stringify(st));
1555
1676
  logWake(`wake: ${fresh.map(i => `${i.kind} "${i.title}" (${i.why})`).join("; ")}`);
1677
+ notify(`${AGENT.name}: new work`, fresh.map(i => i.title).join(" · "));
1678
+ const approvalsInSession = fresh.filter(i => i.key.startsWith("approval:")).map(i => i.key.slice(9));
1679
+ if (approvalsInSession.length) {
1680
+ const list = loadApprovals();
1681
+ for (const a of list) if (approvalsInSession.includes(a.code)) a.wokeAt = new Date().toISOString();
1682
+ saveApprovals(list);
1683
+ }
1556
1684
 
1557
1685
  const before = sentCount();
1558
1686
  const started = Date.now();
@@ -1562,6 +1690,7 @@ async function wake() {
1562
1690
  const wrote = sentCount() > before;
1563
1691
  const tail = r.out.trim().split("\n").slice(-3).join(" ").slice(0, 300);
1564
1692
  logWake(`session exited ${r.code} after ${Math.round((Date.now() - started) / 1000)} s${r.timedOut ? " (time limit)" : ""}${wrote ? "" : ", wrote nothing"}: ${tail}`);
1693
+ await attachReport({ items: fresh, started, r, wrote }).catch(() => {});
1565
1694
  if (r.code === 0 && !r.timedOut) {
1566
1695
  for (const i of fresh) delete st.attempts[i.key];
1567
1696
  saveAtomic(WAKE.state, JSON.stringify(st));
@@ -1592,6 +1721,7 @@ async function wake() {
1592
1721
  message = `My session on ${os.hostname()} ended early (exit ${r.code}) after starting work. What I finished is recorded above. Mention @${AGENT.name} again to pick this up.`;
1593
1722
  }
1594
1723
  if (first) { try { await send("add_comment", { taskID: first.id, text: message }); } catch (e) { logWake(`couldn't post the failure: ${e.message}`); } }
1724
+ notify(`${AGENT.name} needs attention`, message.slice(0, 200));
1595
1725
  return 1;
1596
1726
  } finally {
1597
1727
  try { fs.unlinkSync(WAKE.lock); } catch (_) {}
@@ -1685,6 +1815,444 @@ async function wakeSetup(on) {
1685
1815
  return 0;
1686
1816
  }
1687
1817
 
1818
+ // ------------------------------------------------------------------ 1.8: the PC's own jobs
1819
+ //
1820
+ // Things the companion does on this computer by itself, every scheduled tick
1821
+ // (the same 2-minute job as waking), without spending any Claude usage:
1822
+ // monitors checks that wake Claude only when something changes
1823
+ // approvals a command an assistant may run once the user says so
1824
+ // inbox folder files dropped in a folder become tasks
1825
+ // notifications a toast on this computer when something needs a person
1826
+ // and, after each session, a report attached to the task.
1827
+
1828
+ const X = {
1829
+ monitors: path.join(COMPANION.home, "monitors.json"),
1830
+ approvals: path.join(COMPANION.home, "approvals.json"),
1831
+ monitorWakes: path.join(COMPANION.home, "monitor-wakes.json"),
1832
+ ops: path.join(COMPANION.home, "session-ops.log"),
1833
+ tickLock: path.join(COMPANION.home, "tick.lock"),
1834
+ notified: path.join(COMPANION.home, "notified.json"),
1835
+ };
1836
+ const MONITOR_TYPES = ["http", "port", "disk", "process", "file", "command"];
1837
+ // What an assistant may set up itself. A command monitor runs a shell command
1838
+ // every few minutes, so only the person at this computer can add one.
1839
+ const ASSISTANT_MONITOR_TYPES = ["http", "port", "disk", "process", "file"];
1840
+ const APPROVAL_TTL_MS = 24 * 3600 * 1000;
1841
+ const RUN_LIMIT_MS = 10 * 60 * 1000;
1842
+
1843
+ const localISO = d => new Date(d).toLocaleString();
1844
+ function ensureHome() { fs.mkdirSync(COMPANION.home, { recursive: true }); }
1845
+
1846
+ // ---- operation log (for session reports)
1847
+
1848
+ function logOp(command) {
1849
+ try {
1850
+ ensureHome();
1851
+ const line = { at: new Date().toISOString(), op: command.op, taskID: command.taskID || "", what: String(command.text || command.title || command.displayName || command.pinnedNote || "").slice(0, 90) };
1852
+ fs.appendFileSync(X.ops, JSON.stringify(line) + "\n");
1853
+ // Keep the last 500 lines.
1854
+ const lines = fs.readFileSync(X.ops, "utf8").split("\n").filter(Boolean);
1855
+ if (lines.length > 600) fs.writeFileSync(X.ops, lines.slice(-500).join("\n") + "\n");
1856
+ } catch (_) {}
1857
+ }
1858
+ function opsSince(t) {
1859
+ try {
1860
+ return fs.readFileSync(X.ops, "utf8").split("\n").filter(Boolean).map(l => JSON.parse(l)).filter(o => Date.parse(o.at) >= t);
1861
+ } catch (_) { return []; }
1862
+ }
1863
+
1864
+ // ---- desktop notifications (#8)
1865
+
1866
+ // Shows a notification on this computer. Best effort: a scheduled job with
1867
+ // nobody signed in has no desktop to show it on. WF_NOTIFY_LOG is for tests.
1868
+ function notify(title, body, onceKey) {
1869
+ const cfg = readJSON(CP.config, {}) || {};
1870
+ if (cfg.notifications === false) return;
1871
+ if (onceKey) {
1872
+ const seen = readJSON(X.notified, {}) || {};
1873
+ if (seen[onceKey] && Date.now() - seen[onceKey] < 12 * 3600 * 1000) return;
1874
+ seen[onceKey] = Date.now();
1875
+ try { ensureHome(); saveAtomic(X.notified, JSON.stringify(seen)); } catch (_) {}
1876
+ }
1877
+ title = String(title).slice(0, 80); body = String(body).slice(0, 240);
1878
+ if (process.env.WF_NOTIFY_LOG) { try { fs.appendFileSync(process.env.WF_NOTIFY_LOG, `${title} | ${body}\n`); } catch (_) {} return; }
1879
+ const { spawn } = require("child_process");
1880
+ const opts = { detached: true, stdio: "ignore", windowsHide: true, env: Object.assign({}, process.env, { WF_T: title, WF_B: body }) };
1881
+ try {
1882
+ if (process.platform === "win32") {
1883
+ const ps = "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;" +
1884
+ "$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);" +
1885
+ "$x = $t.GetElementsByTagName('text'); $x.Item(0).AppendChild($t.CreateTextNode($env:WF_T)) > $null; $x.Item(1).AppendChild($t.CreateTextNode($env:WF_B)) > $null;" +
1886
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($t))";
1887
+ spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps], opts).unref();
1888
+ } else if (process.platform === "darwin") {
1889
+ spawn("osascript", ["-e", "display notification (system attribute \"WF_B\") with title (system attribute \"WF_T\")"], opts).unref();
1890
+ } else {
1891
+ spawn("notify-send", [title, body], opts).unref();
1892
+ }
1893
+ } catch (_) {}
1894
+ }
1895
+
1896
+ // ---- monitors (#1)
1897
+
1898
+ function loadMonitors() { return readJSON(X.monitors, []) || []; }
1899
+ function saveMonitors(list) { ensureHome(); saveAtomic(X.monitors, JSON.stringify(list, null, 2)); }
1900
+
1901
+ // One check. Resolves { ok, detail, fingerprint? }; never throws.
1902
+ async function checkMonitor(m) {
1903
+ const target = String(m.target || "");
1904
+ try {
1905
+ if (m.type === "http") {
1906
+ const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 15000);
1907
+ try {
1908
+ const res = await fetch(target, { signal: ctl.signal, redirect: "follow" });
1909
+ const text = m.expectText ? await res.text() : "";
1910
+ if (res.status >= 400) return { ok: false, detail: `HTTP ${res.status}` };
1911
+ if (m.expectText && !text.includes(m.expectText)) return { ok: false, detail: `HTTP ${res.status}, but “${m.expectText}” isn't on the page` };
1912
+ return { ok: true, detail: `HTTP ${res.status}` };
1913
+ } finally { clearTimeout(timer); }
1914
+ }
1915
+ if (m.type === "port") {
1916
+ const [host, port] = [target.slice(0, target.lastIndexOf(":")), Number(target.slice(target.lastIndexOf(":") + 1))];
1917
+ return await new Promise(resolve => {
1918
+ const s = require("net").connect({ host, port, timeout: 5000 });
1919
+ s.on("connect", () => { s.destroy(); resolve({ ok: true, detail: `${target} open` }); });
1920
+ s.on("timeout", () => { s.destroy(); resolve({ ok: false, detail: `${target} timed out` }); });
1921
+ s.on("error", e => resolve({ ok: false, detail: `${target}: ${e.code || e.message}` }));
1922
+ });
1923
+ }
1924
+ if (m.type === "disk") {
1925
+ const st = fs.statfsSync(target);
1926
+ const freeGB = (st.bavail * st.bsize) / 1073741824;
1927
+ const min = Number(m.minFreeGB || 5);
1928
+ return { ok: freeGB >= min, detail: `${freeGB.toFixed(1)} GB free (needs ${min})` };
1929
+ }
1930
+ if (m.type === "process") {
1931
+ const { spawnSync } = require("child_process");
1932
+ const r = process.platform === "win32"
1933
+ ? spawnSync("tasklist", ["/FI", `IMAGENAME eq ${target}`, "/NH"], { encoding: "utf8", windowsHide: true })
1934
+ : spawnSync("pgrep", ["-f", target], { encoding: "utf8" });
1935
+ const running = process.platform === "win32" ? (r.stdout || "").toLowerCase().includes(target.toLowerCase()) : r.status === 0;
1936
+ return { ok: running, detail: running ? `${target} is running` : `${target} isn't running` };
1937
+ }
1938
+ if (m.type === "file") {
1939
+ if (!fs.existsSync(target)) return { ok: false, detail: `${target} is missing`, fingerprint: "missing" };
1940
+ const st = fs.statSync(target);
1941
+ return { ok: true, detail: `${target} changed ${localISO(st.mtime)}`, fingerprint: `${st.size}@${st.mtimeMs}` };
1942
+ }
1943
+ if (m.type === "command") {
1944
+ const { spawnSync } = require("child_process");
1945
+ const r = spawnSync(target, { shell: true, encoding: "utf8", timeout: 30000, windowsHide: true });
1946
+ const last = String((r.stdout || "") + (r.stderr || "")).trim().split("\n").pop() || "";
1947
+ return { ok: r.status === 0, detail: `exit ${r.status === null ? "timeout" : r.status}${last ? `: ${last.slice(0, 120)}` : ""}` };
1948
+ }
1949
+ return { ok: false, detail: `unknown monitor type ${m.type}` };
1950
+ } catch (e) {
1951
+ return { ok: false, detail: String(e.name === "AbortError" ? "timed out" : (e.cause && e.cause.code) || e.message).slice(0, 160) };
1952
+ }
1953
+ }
1954
+
1955
+ // Runs the monitors that are due. A change of state posts a comment on the
1956
+ // monitor's task, shows a notification, and (with wake on) queues a session.
1957
+ async function runMonitors({ force = false } = {}) {
1958
+ const list = loadMonitors();
1959
+ if (!list.length) return [];
1960
+ const changes = [];
1961
+ for (const m of list) {
1962
+ const due = force || !m.checkedAt || Date.now() - Date.parse(m.checkedAt) >= Math.max(2, m.every || 5) * 60000 - 15000;
1963
+ if (!due) continue;
1964
+ const r = await checkMonitor(m);
1965
+ const before = m.state;
1966
+ m.checkedAt = new Date().toISOString();
1967
+ // A file monitor reports a change of the file itself, not up and down.
1968
+ const changed = m.type === "file"
1969
+ ? before && before.fingerprint !== undefined && r.fingerprint !== before.fingerprint
1970
+ : before ? before.ok !== r.ok : !r.ok;
1971
+ const prevSince = m.since;
1972
+ if (!before || changed) m.since = m.checkedAt;
1973
+ m.state = { ok: r.ok, detail: r.detail, fingerprint: r.fingerprint };
1974
+ if (changed) changes.push({ m, r, before, prevSince });
1975
+ }
1976
+ saveMonitors(list);
1977
+ for (const { m, r, before, prevSince } of changes) {
1978
+ const text = m.type === "file"
1979
+ ? `📄 Monitor “${m.name}”: ${r.detail}.`
1980
+ : r.ok ? `✅ Monitor “${m.name}” is OK again: ${r.detail}${before && prevSince ? ` (failing since ${localISO(prevSince)})` : ""}.`
1981
+ : `⚠️ Monitor “${m.name}” FAILED: ${r.detail}.`;
1982
+ notify(`WorkFlow · ${m.name}`, text.replace(/^\S+ /, ""));
1983
+ if (m.task) {
1984
+ try { await send("add_comment", { taskID: m.task, text }); } catch (e) { logWake(`monitor ${m.name}: couldn't comment: ${e.message}`); }
1985
+ }
1986
+ if (m.wake && m.task) {
1987
+ const q = readJSON(X.monitorWakes, []) || [];
1988
+ q.push({ key: `monitor:${m.name}:${Date.now()}`, taskID: m.task, title: m.taskTitle || m.name, why: `monitor “${m.name}” changed: ${r.ok ? "OK" : "FAILED"}, ${r.detail}` });
1989
+ saveAtomic(X.monitorWakes, JSON.stringify(q.slice(-20)));
1990
+ }
1991
+ }
1992
+ return changes;
1993
+ }
1994
+
1995
+ function monitorLine(m) {
1996
+ const s = m.state ? `${m.state.ok ? "OK" : "FAILED"}: ${m.state.detail} (checked ${localISO(m.checkedAt)})` : "not checked yet";
1997
+ return `${m.name} [${m.type} ${m.target}] every ${m.every || 5} min${m.wake ? ", wakes Claude" : ""}${m.taskTitle ? `, task “${m.taskTitle}”` : ""}\n ${s}`;
1998
+ }
1999
+
2000
+ // Adds or replaces a monitor. `by` is "person" (the command line) or
2001
+ // "assistant" (the MCP tool), which may not add command monitors.
2002
+ function addMonitor(spec, by) {
2003
+ const name = String(spec.name || "").trim();
2004
+ if (!/^[\w .-]{1,40}$/.test(name)) throw new Error("Give the monitor a short name (letters, digits, spaces, . _ -).");
2005
+ if (!MONITOR_TYPES.includes(spec.type)) throw new Error(`Type must be one of: ${MONITOR_TYPES.join(", ")}.`);
2006
+ if (by === "assistant" && !ASSISTANT_MONITOR_TYPES.includes(spec.type)) {
2007
+ throw new Error("Only the person at this computer can add a command monitor: it runs a command every few minutes. Ask them to run: workflow-companion monitor add --command \"…\"");
2008
+ }
2009
+ if (!spec.target) throw new Error("Say what to check (a URL, host:port, a folder, a process name, a file, or a command).");
2010
+ if (spec.type === "http" && !/^https?:\/\//i.test(spec.target)) throw new Error("An http monitor needs a URL starting http:// or https://.");
2011
+ if (spec.type === "port" && !/^.+:\d{1,5}$/.test(spec.target)) throw new Error("A port monitor needs host:port.");
2012
+ const list = loadMonitors().filter(m => m.name.toLowerCase() !== name.toLowerCase());
2013
+ const m = { name, type: spec.type, target: String(spec.target), every: Math.min(1440, Math.max(2, Number(spec.every) || 5)),
2014
+ wake: !!spec.wake, task: spec.task || "", taskTitle: spec.taskTitle || "", by, addedAt: new Date().toISOString() };
2015
+ if (spec.minFreeGB) m.minFreeGB = Number(spec.minFreeGB);
2016
+ if (spec.expectText) m.expectText = String(spec.expectText);
2017
+ list.push(m);
2018
+ if (list.length > 30) throw new Error("That's 30 monitors already; remove one first.");
2019
+ saveMonitors(list);
2020
+ return m;
2021
+ }
2022
+
2023
+ function removeMonitor(name) {
2024
+ const list = loadMonitors();
2025
+ const kept = list.filter(m => m.name.toLowerCase() !== String(name).toLowerCase());
2026
+ if (kept.length === list.length) throw new Error(`No monitor called “${name}”.`);
2027
+ saveMonitors(kept);
2028
+ }
2029
+
2030
+ // ---- approvals (#3)
2031
+
2032
+ function loadApprovals() { return readJSON(X.approvals, []) || []; }
2033
+ function saveApprovals(list) { ensureHome(); saveAtomic(X.approvals, JSON.stringify(list.slice(-100), null, 2)); }
2034
+ function approvalCode() {
2035
+ const abc = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // nothing that reads as another letter
2036
+ return Array.from(require("crypto").randomBytes(4)).map(b => abc[b % abc.length]).join("");
2037
+ }
2038
+
2039
+ // The person's answer to an approval, from the task's thread: "approve K7Q2"
2040
+ // or "decline K7Q2" in a comment written after the request by a person, never
2041
+ // an assistant. Returns { decision, by, at, commentID } or null.
2042
+ function approvalAnswer(a, snap) {
2043
+ const t = (snap.tasks || []).find(x => x.id === a.taskID);
2044
+ if (!t) return null;
2045
+ const shared = new Set((snap.projects || []).filter(p => p.isShared).map(p => p.name));
2046
+ if (t.project && shared.has(t.project)) return null;
2047
+ const re = new RegExp(`(^|[^A-Za-z0-9])(approve|approved|decline|declined|deny|no)\\s+${a.code}(?![A-Za-z0-9])`, "i");
2048
+ for (const c of (t.comments || []).slice().reverse()) {
2049
+ if (isAgentID(c.authorID)) continue;
2050
+ if (Date.parse(c.timestamp) < Date.parse(a.requestedAt)) break;
2051
+ const m = re.exec(c.text || "");
2052
+ if (m) return { decision: /^approv/i.test(m[2]) ? "approved" : "declined", by: c.author || "?", at: c.timestamp, commentID: c.id };
2053
+ }
2054
+ return null;
2055
+ }
2056
+
2057
+ // Brings each pending approval up to date with the snapshot.
2058
+ function refreshApprovals(snap) {
2059
+ const list = loadApprovals();
2060
+ let changed = false;
2061
+ const decided = [];
2062
+ for (const a of list) {
2063
+ if (a.status !== "pending") continue;
2064
+ if (Date.now() > Date.parse(a.expiresAt)) { a.status = "expired"; changed = true; continue; }
2065
+ const ans = snap ? approvalAnswer(a, snap) : null;
2066
+ if (ans) { Object.assign(a, { status: ans.decision, decidedBy: ans.by, decidedAt: ans.at, decisionComment: ans.commentID }); changed = true; decided.push(a); }
2067
+ }
2068
+ if (changed) saveApprovals(list);
2069
+ return decided;
2070
+ }
2071
+
2072
+ function findApproval(code) {
2073
+ const a = loadApprovals().find(x => x.code === String(code || "").trim().toUpperCase());
2074
+ if (!a) throw new Error(`No approval ${code} on this computer.`);
2075
+ return a;
2076
+ }
2077
+
2078
+ // Runs an approved command, exactly as it was when the person approved it.
2079
+ function runApproved(a) {
2080
+ const { spawnSync } = require("child_process");
2081
+ const started = Date.now();
2082
+ const r = spawnSync(a.command, { shell: true, encoding: "utf8", timeout: RUN_LIMIT_MS, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
2083
+ cwd: a.cwd && fs.existsSync(a.cwd) ? a.cwd : os.homedir() });
2084
+ const output = String((r.stdout || "") + (r.stderr || "")).trim();
2085
+ return { exit: r.status === null ? "timed out" : r.status, seconds: Math.round((Date.now() - started) / 1000), output: output.slice(-6000) };
2086
+ }
2087
+
2088
+ // ---- inbox folder (#7)
2089
+
2090
+ function inboxFolder(cfg) { return cfg && cfg.inboxFolder ? cfg.inboxFolder : null; }
2091
+
2092
+ // Files dropped in the folder (or a subfolder named after a project) become
2093
+ // tasks with the file attached, then move to "Filed". Up to 5 a tick; a file
2094
+ // still being written (changed in the last 10 s) waits for the next one.
2095
+ async function runInbox(cfg) {
2096
+ const root = inboxFolder(cfg);
2097
+ if (!root || !fs.existsSync(root)) return [];
2098
+ const done = [];
2099
+ const skip = n => n.startsWith(".") || /\.(tmp|part|crdownload|download)$/i.test(n) || n === "Filed" || /^desktop\.ini$|^thumbs\.db$/i.test(n);
2100
+ const candidates = [];
2101
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
2102
+ if (skip(entry.name)) continue;
2103
+ if (entry.isFile()) candidates.push({ file: path.join(root, entry.name), project: cfg.inboxProject || "" });
2104
+ else if (entry.isDirectory()) {
2105
+ for (const sub of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
2106
+ if (sub.isFile() && !skip(sub.name)) candidates.push({ file: path.join(root, entry.name, sub.name), project: entry.name });
2107
+ }
2108
+ }
2109
+ }
2110
+ for (const c of candidates.slice(0, 5)) {
2111
+ const st = fs.statSync(c.file);
2112
+ if (Date.now() - st.mtimeMs < 10000) continue;
2113
+ const name = path.basename(c.file);
2114
+ try {
2115
+ const created = await send("create_task", { title: name.replace(/\.[^.]+$/, ""), project: c.project || undefined,
2116
+ notes: `Dropped into ${root} on ${os.hostname()}.`, assignee: cfg.inboxAssign ? AGENT.name : undefined, hasDueDate: false });
2117
+ if (created.status !== "applied" || !created.taskID) throw new Error(created.message || "the task wasn't created");
2118
+ const staged = await stageFile(c.file);
2119
+ const att = await sendWithFile("attach_file", { taskID: created.taskID, displayName: name }, staged, 15000);
2120
+ if (att.status === "failed" || att.status === "rejected") throw new Error(att.message || "the file wasn't attached");
2121
+ const filed = path.join(root, "Filed", new Date().toISOString().slice(0, 10));
2122
+ fs.mkdirSync(filed, { recursive: true });
2123
+ let dest = path.join(filed, name);
2124
+ if (fs.existsSync(dest)) dest = path.join(filed, `${Date.now()}-${name}`);
2125
+ fs.renameSync(c.file, dest);
2126
+ done.push(name);
2127
+ logWake(`inbox: “${name}” → task ${created.taskID}`);
2128
+ } catch (e) {
2129
+ logWake(`inbox: “${name}” not filed: ${e.message}`);
2130
+ }
2131
+ }
2132
+ if (done.length) notify("WorkFlow", `${done.length === 1 ? `“${done[0]}” is` : `${done.length} files are`} now in WorkFlow.`);
2133
+ return done;
2134
+ }
2135
+
2136
+ // ---- the tick: everything above, once, without overlapping itself
2137
+
2138
+ async function tickExtras() {
2139
+ const cfg = readJSON(CP.config, null);
2140
+ if (!cfg) return;
2141
+ ensureHome();
2142
+ // One tick at a time; a holder older than 10 minutes is stale.
2143
+ try { const fd = fs.openSync(X.tickLock, "wx"); fs.closeSync(fd); }
2144
+ catch (_) {
2145
+ let age = 0; try { age = Date.now() - fs.statSync(X.tickLock).mtimeMs; } catch (_) {}
2146
+ if (age < 10 * 60000) return;
2147
+ try { fs.writeFileSync(X.tickLock, ""); } catch (_) { return; }
2148
+ }
2149
+ try {
2150
+ await runMonitors().catch(e => logWake(`monitors: ${e.message}`));
2151
+ await runInbox(cfg).catch(e => logWake(`inbox: ${e.message}`));
2152
+ const decided = refreshApprovals(cloudSnap);
2153
+ for (const a of decided) notify(`WorkFlow · approval ${a.code}`, `${a.decidedBy} ${a.status} “${a.action}”.`);
2154
+ } finally { try { fs.unlinkSync(X.tickLock); } catch (_) {} }
2155
+ }
2156
+
2157
+ // Makes sure the 2-minute job exists (monitors and the inbox folder need it
2158
+ // even with waking off). Respects WF_NO_SCHEDULE and --no-schedule.
2159
+ function ensureTick() {
2160
+ if (argv.includes("--no-schedule") || process.env.WF_NO_SCHEDULE) return "not scheduled (WF_NO_SCHEDULE)";
2161
+ return installWakeSchedule();
2162
+ }
2163
+
2164
+ // ---- session report (#4)
2165
+
2166
+ async function attachReport({ items, started, r, wrote }) {
2167
+ const cfg = readJSON(CP.config, {}) || {};
2168
+ const first = items.find(i => i.kind === "task");
2169
+ if (cfg.reports === false || !first || !wrote) return;
2170
+ const ops = opsSince(started);
2171
+ const ran = loadApprovals().filter(a => a.ranAt && Date.parse(a.ranAt) >= started);
2172
+ const final = r.out.trim().split("\n").slice(-40).join("\n").slice(-3000);
2173
+ const md = [
2174
+ `# Session report: ${AGENT.name} on ${os.hostname()}`,
2175
+ "",
2176
+ `- Started: ${localISO(started)}`,
2177
+ `- Took: ${Math.max(1, Math.round((Date.now() - started) / 60000))} min`,
2178
+ `- Outcome: ${r.timedOut ? "stopped at the time limit" : r.code === 0 ? "finished" : `ended early (exit ${r.code})`}`,
2179
+ `- Asked about: ${items.map(i => `${i.title} (${i.why})`).join("; ")}`,
2180
+ "",
2181
+ "## What it changed in WorkFlow",
2182
+ ...(ops.length ? ops.map(o => `- ${localISO(o.at).split(", ").pop()} ${o.op}${o.what ? `: ${o.what}` : ""}`) : ["- nothing"]),
2183
+ ...(ran.length ? ["", "## Commands it ran (approved)", ...ran.map(a => `- \`${a.command}\`: exit ${a.result && a.result.exit} (approved by ${a.decidedBy})`)] : []),
2184
+ "",
2185
+ "## Its last words",
2186
+ "",
2187
+ "```",
2188
+ final || "(nothing)",
2189
+ "```",
2190
+ "",
2191
+ ].join("\n");
2192
+ const file = path.join(os.tmpdir(), `session-report-${Date.now()}.md`);
2193
+ fs.writeFileSync(file, md);
2194
+ try {
2195
+ const staged = await stageFile(file);
2196
+ const stamp = new Date(started).toLocaleString([], { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
2197
+ await sendWithFile("attach_file", { taskID: first.id, displayName: `Session report ${stamp}.md` }, staged, 15000);
2198
+ } catch (e) { logWake(`report not attached: ${e.message}`); }
2199
+ finally { try { fs.unlinkSync(file); } catch (_) {} }
2200
+ }
2201
+
2202
+ // ---- command line
2203
+
2204
+ function monitorCLI() {
2205
+ const sub = argv[argv.indexOf("monitor") + 1];
2206
+ if (sub === "list" || !sub) {
2207
+ const list = loadMonitors();
2208
+ console.log(list.length ? list.map(monitorLine).join("\n") : "No monitors. Add one with: workflow-companion monitor add --name site --http https://example.com --task \"Server status\" --wake");
2209
+ return Promise.resolve(0);
2210
+ }
2211
+ if (sub === "remove") { removeMonitor(argv[argv.indexOf("remove") + 1]); console.log("Removed."); return Promise.resolve(0); }
2212
+ if (sub === "check") {
2213
+ return (async () => {
2214
+ for (const m of loadMonitors()) { const r = await checkMonitor(m); console.log(`${m.name}: ${r.ok ? "OK" : "FAILED"}: ${r.detail}`); }
2215
+ return 0;
2216
+ })();
2217
+ }
2218
+ if (sub === "add") {
2219
+ return (async () => {
2220
+ const type = MONITOR_TYPES.find(t => argv.includes(`--${t}`));
2221
+ if (!type) throw new Error(`Say what to check: --http URL, --port host:port, --disk PATH, --process NAME, --file PATH or --command "…"`);
2222
+ let task = "", taskTitle = "";
2223
+ if (arg("task")) {
2224
+ await cloud.load(true);
2225
+ const t = findTask(arg("task"));
2226
+ task = t.id; taskTitle = t.title;
2227
+ }
2228
+ const m = addMonitor({ name: arg("name"), type, target: arg(type), every: arg("every"), wake: argv.includes("--wake"),
2229
+ task, taskTitle, minFreeGB: arg("min-gb"), expectText: arg("expect") }, "person");
2230
+ const r = await checkMonitor(m);
2231
+ console.log(`Monitor “${m.name}” added: ${r.ok ? "OK" : "FAILED"} now (${r.detail}). Checked every ${m.every} min${task ? `; changes are posted on “${taskTitle}”` : ""}${m.wake ? " and wake Claude" : ""}.`);
2232
+ console.log(`Schedule: ${ensureTick()}.`);
2233
+ return 0;
2234
+ })();
2235
+ }
2236
+ throw new Error("monitor list | add | remove NAME | check");
2237
+ }
2238
+
2239
+ function setFlag(key, on, sayOn, sayOff) {
2240
+ if (!fs.existsSync(CP.config)) { console.error(signInHelp("Not signed in yet.")); return 2; }
2241
+ updateConfig(cfg => { cfg[key] = on; });
2242
+ console.log(on ? sayOn : sayOff);
2243
+ return 0;
2244
+ }
2245
+
2246
+ async function inboxCLI(on) {
2247
+ if (!fs.existsSync(CP.config)) { console.error(signInHelp("Not signed in yet.")); return 2; }
2248
+ if (!on) { updateConfig(cfg => { delete cfg.inboxFolder; }); console.log("The inbox folder is off. Files in it stay where they are."); return 0; }
2249
+ const folder = path.resolve(String(arg("folder") || path.join(os.homedir(), "WorkFlow Inbox")).replace(/^~/, os.homedir()));
2250
+ fs.mkdirSync(folder, { recursive: true });
2251
+ updateConfig(cfg => { cfg.inboxFolder = folder; cfg.inboxProject = arg("project") || ""; cfg.inboxAssign = argv.includes("--assign"); });
2252
+ console.log(`Inbox folder: ${folder}\n Drop a file in and within a couple of minutes it's a task in WorkFlow with the file attached${arg("project") ? ` (project ${arg("project")})` : ""}; a subfolder named after a project files it there. Filed files move to ${path.join(folder, "Filed")}.\n Schedule: ${ensureTick()}.`);
2253
+ return 0;
2254
+ }
2255
+
1688
2256
  // ------------------------------------------------------------------ versions (1.6.1)
1689
2257
 
1690
2258
  // Each companion says which version it runs, and keeps itself current.
@@ -1918,10 +2486,20 @@ const HELP = `WorkFlow companion ${SERVER_INFO.version}
1918
2486
  (--allow-tools, --model, --workdir, --max-minutes, --claude, --skip-existing)
1919
2487
  workflow-companion wake-off stop doing that; work waits until you ask
1920
2488
  workflow-companion wake one check of the queue (what the scheduled job runs)
2489
+ workflow-companion monitor list | check | remove NAME
2490
+ workflow-companion monitor add --name N (--http URL | --port HOST:PORT | --disk PATH --min-gb G |
2491
+ --process NAME | --file PATH | --command "…") [--task T] [--every MIN] [--wake]
2492
+ checks this computer runs without using Claude; changes are posted on the task
2493
+ workflow-companion approvals on|off let an assistant run a command once you reply "approve CODE" (off by default)
2494
+ workflow-companion inbox-on [--folder PATH] [--project P] [--assign] files dropped in the folder become tasks
2495
+ workflow-companion inbox-off
2496
+ workflow-companion notifications on|off notices on this computer (new work, monitors, sign-in ended)
2497
+ workflow-companion reports on|off attach a short report to the task after each session (on by default)
1921
2498
  workflow-companion signout forget the sign-in on this computer
1922
2499
  workflow-companion --print-config
1923
2500
 
1924
2501
  Needs WorkFlow 5.2+ on an iPhone, iPad or Mac, with the companion switched on in its Settings.
2502
+ Install in one line: see https://github.com/TeamDzX/workflow-companion#install
1925
2503
  `;
1926
2504
 
1927
2505
  if (argv.includes("--print-config")) printConfig();
@@ -1933,6 +2511,16 @@ else if (verb === "update") updateNow().then(c => { process.exitCode = c; }, e =
1933
2511
  else if (verb === "auto-update") process.exitCode = setAutoUpdate(argv[argv.indexOf("auto-update") + 1] !== "off");
1934
2512
  else if (verb === "wake") wake().then(c => { process.exitCode = c; }, e => { logWake(`failed: ${e.message || e}`); process.exitCode = 1; });
1935
2513
  else if (verb === "wake-on" || verb === "wake-off") wakeSetup(verb === "wake-on").then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
2514
+ else if (verb === "monitor") monitorCLI().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
2515
+ else if (verb === "approvals") process.exitCode = setFlag("approvalsEnabled", argv[argv.indexOf("approvals") + 1] === "on",
2516
+ "Approvals are on. An assistant can ask to run one exact command on this computer; it runs only after you reply \"approve CODE\" on the task in WorkFlow, once, within 24 hours. Turn off with: workflow-companion approvals off",
2517
+ "Approvals are off. Requests are refused and approved ones won't run.");
2518
+ else if (verb === "inbox-on" || verb === "inbox-off") inboxCLI(verb === "inbox-on").then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
2519
+ else if (verb === "notifications") process.exitCode = setFlag("notifications", argv[argv.indexOf("notifications") + 1] !== "off",
2520
+ "Notifications are on.", "Notifications are off.");
2521
+ else if (verb === "reports") process.exitCode = setFlag("reports", argv[argv.indexOf("reports") + 1] !== "off",
2522
+ "Session reports are on: each session attaches a short report to its task.", "Session reports are off.");
2523
+ else if (verb === "notify-test") { notify("WorkFlow companion", `Notifications work on ${os.hostname()}.`); console.log("Sent a test notification."); }
1936
2524
  else if (verb === "signout") {
1937
2525
  removeSchedule();
1938
2526
  removeWakeSchedule();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opticell/workflow-companion",
3
- "version": "1.7.0",
3
+ "version": "1.8.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": [