@opticell/workflow-companion 1.7.0 → 1.8.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.
Files changed (3) hide show
  1. package/README.md +95 -2
  2. package/index.js +720 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # WorkFlow Companion
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@opticell/workflow-companion?label=npm)](https://www.npmjs.com/package/@opticell/workflow-companion)
4
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-io.github.TeamDzX%2Fworkflow--companion-4F46E5)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.TeamDzX/workflow-companion)
5
+
6
+ > **This repository** is WorkFlow Companion's home: the source (`src/index.js`,
7
+ > MIT licensed), downloads (`WorkFlow.mcpb` for Claude Desktop) on the
8
+ > [Releases](https://github.com/TeamDzX/workflow-companion/releases) page,
9
+ > and bugs and requests in [Issues](https://github.com/TeamDzX/workflow-companion/issues).
10
+ > The package itself installs from npm. On a Mac, use the open-source
11
+ > [workflow-agent-bridge](https://github.com/TeamDzX/workflow-agent-bridge) instead.
12
+ > All of Opticell's free tools for Claude: [opticell-mcp](https://github.com/TeamDzX/opticell-mcp).
13
+
3
14
  Lets **Claude on Windows or Linux** work in your WorkFlow tasks, notes and
4
15
  projects: reading the queue, replying in threads, ticking checklists, filing
5
16
  notes, updating documents. It works in Claude Code, Claude Desktop, or any MCP
@@ -26,6 +37,26 @@ away in the app switcher. With both switched on, the Mac answers while it's
26
37
  awake and the iPhone stands by. Every command is applied exactly once, however
27
38
  many devices are on.
28
39
 
40
+ ## Install
41
+
42
+ One line installs Node.js if needed, signs this computer in, adds the
43
+ companion to Claude Code and offers to let it start work on its own:
44
+
45
+ **Windows** (PowerShell):
46
+
47
+ ```powershell
48
+ irm https://raw.githubusercontent.com/TeamDzX/workflow-companion/main/install.ps1 | iex
49
+ ```
50
+
51
+ **Mac or Linux** (Terminal):
52
+
53
+ ```sh
54
+ curl -fsSL https://raw.githubusercontent.com/TeamDzX/workflow-companion/main/install.sh | sh
55
+ ```
56
+
57
+ Switch the companion on in WorkFlow first (step 1 below). The steps that
58
+ follow are what the installer does, if you'd rather do them by hand.
59
+
29
60
  ## Set up
30
61
 
31
62
  1. In WorkFlow on an iPhone or iPad: **Settings → Windows Companion → Answer
@@ -149,14 +180,61 @@ npx -y @opticell/workflow-companion wake-on
149
180
  | `workflow-companion` | Runs the MCP server. This is what Claude starts. |
150
181
  | `workflow-companion setup` | Signs this computer in. Takes `--web-token` to skip the prompt. |
151
182
  | `workflow-companion status` | Which device answers, how fresh the snapshot is, any receipts that arrived late. |
183
+ | `workflow-companion doctor` | Checks everything on this computer and says how to fix what's wrong. Start here when something isn't working. |
152
184
  | `workflow-companion keepalive` | Renews the sign-in. The scheduled job runs this. |
153
185
  | `workflow-companion update` | Updates the scheduled jobs' copy to the latest release now, and shows how to keep Claude Code on `@latest`. |
154
186
  | `workflow-companion auto-update on\|off` | Whether the scheduled jobs update themselves (on by default). |
155
187
  | `workflow-companion wake-on` | Starts a Claude Code session on its own when work for its name arrives. See above. |
156
188
  | `workflow-companion wake-off` | Stops that. Work waits until you ask Claude here. |
157
189
  | `workflow-companion wake` | One check of the queue. The scheduled job runs this. |
190
+ | `workflow-companion monitor add\|list\|check\|remove` | Checks this computer runs without using Claude. See below. |
191
+ | `workflow-companion approvals on\|off` | Lets an assistant run one exact command after you reply "approve CODE" (off by default). |
192
+ | `workflow-companion inbox-on\|inbox-off` | Files dropped in a folder become tasks. |
193
+ | `workflow-companion notifications on\|off` | Notices on this computer (on by default). `notify-test` shows one. |
194
+ | `workflow-companion reports on\|off` | A short report attached to the task after each session (on by default). |
158
195
  | `workflow-companion signout` | Deletes the sign-in, cached data and both scheduled jobs. |
159
196
 
197
+ ## On this computer, by itself (1.8)
198
+
199
+ These run in the same 2-minute scheduled job as waking, and cost no Claude
200
+ usage.
201
+
202
+ **Monitors.** Checks that wake Claude only when something changes:
203
+
204
+ ```sh
205
+ workflow-companion monitor add --name site --http https://example.com --task "Server status" --wake
206
+ workflow-companion monitor add --name disk --disk C:\ --min-gb 20 --task "Server status"
207
+ workflow-companion monitor add --name api --port 10.0.0.5:443 --every 2
208
+ workflow-companion monitor add --name backup --file D:\backups\latest.zip --task "Backups"
209
+ workflow-companion monitor add --name nightly --command "check-backups.cmd" --task "Backups" --wake
210
+ ```
211
+
212
+ A change (down, back up, the file changed) is posted on the task, shows a
213
+ notification here, and with `--wake` starts a session about it. An assistant
214
+ can set up website, port, disk, process and file monitors itself
215
+ (`workflow_monitor`), but **only you can add a command monitor**, because it
216
+ runs a command every few minutes.
217
+
218
+ **Approvals.** An assistant can ask to run one exact command on this computer
219
+ (restart a service, install an update). It posts the command on the task with
220
+ a code; reply `approve K7Q2` (or `decline K7Q2`) from your phone. Only a
221
+ person's reply counts, never another assistant's; tasks in shared projects
222
+ can't ask; the command runs once, exactly as shown, within 24 hours. **Off
223
+ until you run `workflow-companion approvals on` on this computer.**
224
+
225
+ **Inbox folder.** `workflow-companion inbox-on` makes `~/WorkFlow Inbox`
226
+ (or `--folder`): drop a file in and it becomes a task with the file attached.
227
+ A subfolder named after a project files it into that project. Filed files
228
+ move to `Filed/<date>`. `--assign` puts them in this assistant's queue.
229
+
230
+ **Notifications.** A notice on this computer when work arrives, a monitor
231
+ changes, an approval is answered, a session needs attention, or the iCloud
232
+ sign-in has ended (with the command that fixes it).
233
+
234
+ **Session reports.** After each session that changed something, a short
235
+ report is attached to its task: what it was asked, what it changed, any
236
+ approved commands it ran, and its last words.
237
+
160
238
  ## Worth knowing
161
239
 
162
240
  - **Something has to be answering.** A command sent while nothing is waits in
@@ -184,11 +262,26 @@ npx -y @opticell/workflow-companion wake-on
184
262
 
185
263
  ## Tools
186
264
 
187
- The same 29 tools as the Mac's Agent Bridge MCP server, including:
265
+ 36 tools, including:
188
266
  `workflow_queue`, `workflow_get_task`, `workflow_comment`,
189
267
  `workflow_set_status_line`, `workflow_check_subtasks`, `workflow_update_task`,
190
268
  `workflow_create_task`, `workflow_list_notes`, `workflow_reply_note`,
191
269
  `workflow_list_projects`, `workflow_list_documents`, `workflow_update_document`,
192
- `workflow_draft_email` and `workflow_state`.
270
+ `workflow_draft_email`, `workflow_state`, and from 1.7 and 1.8
271
+ `workflow_hand_off`, `workflow_watch`, `workflow_house_rules`,
272
+ `workflow_monitor`, `workflow_request_approval`, `workflow_check_approval`
273
+ and `workflow_run_approved`.
193
274
 
194
275
  © Opticell Limited · <https://www.opticell-limited.com/companion>
276
+
277
+ ## Source and licence
278
+
279
+ `src/index.js` is the whole server: one file, Node 18 or later, no
280
+ dependencies. It is MIT licensed (see `LICENSE`). The published npm package
281
+ is the same file with WorkFlow's CloudKit API token filled in at build time
282
+ (the token is not a secret: it only lets a signed-in user reach their own
283
+ data). Run from source, `setup` asks for it with `--api-token`.
284
+
285
+ The `Dockerfile` runs the server over stdio. MCP directories use it to check
286
+ that the server starts and lists its tools, which it does without signing in.
287
+
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.1" };
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,89 @@ 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
+ },
1171
+ {
1172
+ name: "workflow_doctor",
1173
+ description: "Check the companion on this computer end to end (sign-in, WorkFlow publishing and answering, version, scheduled jobs, Claude Code, last session, monitors) and say how to fix anything wrong. Use it when something isn't working.",
1174
+ inputSchema: { type: "object", properties: {} },
1175
+ handler: async () => (await doctorReport({ fromTool: true })).text,
1176
+ },
1093
1177
  {
1094
1178
  name: "workflow_ping",
1095
1179
  description: "Confirm the app is watching the bridge (round-trips one command).",
@@ -1174,6 +1258,12 @@ async function cloudStateText() {
1174
1258
  if (lastKeepAlive) lines.push(`keep-alive last run ${lastKeepAlive}`);
1175
1259
  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
1260
  lines.push(`version ${await versionLine()}`);
1261
+ const mons = loadMonitors();
1262
+ 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)`);
1263
+ const pending = loadApprovals().filter(a => a.status === "pending");
1264
+ lines.push(`approvals ${cfg.approvalsEnabled ? `on${pending.length ? `, ${pending.length} waiting: ${pending.map(a => a.code).join(", ")}` : ""}` : "off (workflow-companion approvals on)"}`);
1265
+ if (cfg.inboxFolder) lines.push(`inbox ${cfg.inboxFolder}`);
1266
+ lines.push(`notices notifications ${cfg.notifications === false ? "off" : "on"}, session reports ${cfg.reports === false ? "off" : "on"}`);
1177
1267
  lines.push(`name ${AGENT.name} — assign tasks to "${AGENT.name}", or write @${AGENT.name} in a comment or note`);
1178
1268
  try {
1179
1269
  await cloud.load(true);
@@ -1319,10 +1409,18 @@ async function keepAlive() {
1319
1409
  return 0;
1320
1410
  } catch (e) {
1321
1411
  logKeepAlive(`${at} ${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "failed"}: ${String(e.message).split("\n")[0]}`);
1412
+ if (e instanceof SignInNeeded) signInEnded();
1322
1413
  return 1;
1323
1414
  }
1324
1415
  }
1325
1416
 
1417
+ // The iCloud sign-in has ended (#5). With no sign-in the companion can't
1418
+ // reach iCloud to tell the phone, so it says so on this computer; WorkFlow
1419
+ // shows the companion going quiet in Who's Bridged.
1420
+ function signInEnded() {
1421
+ 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");
1422
+ }
1423
+
1326
1424
  function logKeepAlive(line) {
1327
1425
  let lines = [];
1328
1426
  try { lines = fs.readFileSync(KEEPALIVE_LOG, "utf8").trim().split("\n"); } catch (_) {}
@@ -1470,7 +1568,7 @@ Do the work, then report back in the task's own thread so it reaches the user's
1470
1568
  workflow_complete_task only when the task is plainly finished
1471
1569
  For a [note], answer with workflow_reply_note, or workflow_convert_note if it describes work worth tracking.
1472
1570
 
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.
1571
+ 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
1572
 
1475
1573
  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
1574
 
@@ -1528,18 +1626,47 @@ async function wake() {
1528
1626
  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
1627
  const cfg = readJSON(CP.config, null);
1530
1628
  if (!cfg) { console.error(signInHelp("Not signed in yet.")); return 2; }
1629
+ // 1.8: monitors, the inbox folder and approvals run on every tick, with
1630
+ // waking on or off, and never wait behind a running session.
1631
+ const extras = loadMonitors().length || inboxFolder(cfg) || loadApprovals().some(a => a.status === "pending");
1632
+ if (!cfg.wakeEnabled && !extras) return 0;
1633
+ if (extras) {
1634
+ try { await cloud.load(true); await reportPresence(); await tickExtras(); }
1635
+ catch (e) {
1636
+ logWake(`${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "iCloud failed"}: ${String(e.message).split("\n")[0]}`);
1637
+ if (e instanceof SignInNeeded) signInEnded();
1638
+ return 1;
1639
+ }
1640
+ }
1531
1641
  if (!cfg.wakeEnabled) return 0;
1532
1642
  const maxMinutes = cfg.wakeMaxMinutes || WAKE.maxMinutes;
1533
1643
  if (!takeWakeLock(maxMinutes)) return 0; // a session is already running
1534
1644
  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();
1645
+ try { if (!extras) await cloud.load(true); }
1646
+ catch (e) {
1647
+ logWake(`${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "iCloud failed"}: ${String(e.message).split("\n")[0]}`);
1648
+ if (e instanceof SignInNeeded) signInEnded();
1649
+ return 1;
1650
+ }
1651
+ if (!extras) await reportPresence();
1538
1652
  if (!cloudSnap) return 0;
1539
1653
  if (blockingClash()) { logWake(`not waking: "${AGENT.name}" is also Claude on another computer`); return 0; }
1540
1654
  const st = readJSON(WAKE.state, null) || {};
1541
1655
  st.seen = st.seen || {}; st.attempts = st.attempts || {};
1542
1656
  const items = wakeItems(cloudSnap);
1657
+ // 1.8: an answered approval wakes the session that asked, once.
1658
+ for (const a of loadApprovals()) {
1659
+ if ((a.status === "approved" || a.status === "declined") && !a.wokeAt) {
1660
+ items.push({ key: `approval:${a.code}`, sig: String(a.decisionComment || a.decidedAt), kind: "task", id: a.taskID, title: a.taskTitle,
1661
+ project: "", why: `your approval request ${a.code} was ${a.status} by ${a.decidedBy}${a.status === "approved" ? "; run it with workflow_run_approved" : ""}` });
1662
+ }
1663
+ }
1664
+ // 1.8: a monitor that changed and is set to wake.
1665
+ const monitorWakes = readJSON(X.monitorWakes, []) || [];
1666
+ if (monitorWakes.length) {
1667
+ 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 });
1668
+ try { fs.unlinkSync(X.monitorWakes); } catch (_) {}
1669
+ }
1543
1670
  // Forget what has left the queue, so it can wake again if it comes back.
1544
1671
  const live = new Set(items.map(i => i.key));
1545
1672
  for (const k of Object.keys(st.seen)) if (!live.has(k)) { delete st.seen[k]; delete st.attempts[k]; }
@@ -1553,6 +1680,13 @@ async function wake() {
1553
1680
  for (const i of fresh) { prior[i.key] = st.seen[i.key]; st.seen[i.key] = i.sig; }
1554
1681
  saveAtomic(WAKE.state, JSON.stringify(st));
1555
1682
  logWake(`wake: ${fresh.map(i => `${i.kind} "${i.title}" (${i.why})`).join("; ")}`);
1683
+ notify(`${AGENT.name}: new work`, fresh.map(i => i.title).join(" · "));
1684
+ const approvalsInSession = fresh.filter(i => i.key.startsWith("approval:")).map(i => i.key.slice(9));
1685
+ if (approvalsInSession.length) {
1686
+ const list = loadApprovals();
1687
+ for (const a of list) if (approvalsInSession.includes(a.code)) a.wokeAt = new Date().toISOString();
1688
+ saveApprovals(list);
1689
+ }
1556
1690
 
1557
1691
  const before = sentCount();
1558
1692
  const started = Date.now();
@@ -1562,6 +1696,7 @@ async function wake() {
1562
1696
  const wrote = sentCount() > before;
1563
1697
  const tail = r.out.trim().split("\n").slice(-3).join(" ").slice(0, 300);
1564
1698
  logWake(`session exited ${r.code} after ${Math.round((Date.now() - started) / 1000)} s${r.timedOut ? " (time limit)" : ""}${wrote ? "" : ", wrote nothing"}: ${tail}`);
1699
+ await attachReport({ items: fresh, started, r, wrote }).catch(() => {});
1565
1700
  if (r.code === 0 && !r.timedOut) {
1566
1701
  for (const i of fresh) delete st.attempts[i.key];
1567
1702
  saveAtomic(WAKE.state, JSON.stringify(st));
@@ -1592,6 +1727,7 @@ async function wake() {
1592
1727
  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
1728
  }
1594
1729
  if (first) { try { await send("add_comment", { taskID: first.id, text: message }); } catch (e) { logWake(`couldn't post the failure: ${e.message}`); } }
1730
+ notify(`${AGENT.name} needs attention`, message.slice(0, 200));
1595
1731
  return 1;
1596
1732
  } finally {
1597
1733
  try { fs.unlinkSync(WAKE.lock); } catch (_) {}
@@ -1685,6 +1821,563 @@ async function wakeSetup(on) {
1685
1821
  return 0;
1686
1822
  }
1687
1823
 
1824
+ // ------------------------------------------------------------------ 1.8: the PC's own jobs
1825
+ //
1826
+ // Things the companion does on this computer by itself, every scheduled tick
1827
+ // (the same 2-minute job as waking), without spending any Claude usage:
1828
+ // monitors checks that wake Claude only when something changes
1829
+ // approvals a command an assistant may run once the user says so
1830
+ // inbox folder files dropped in a folder become tasks
1831
+ // notifications a toast on this computer when something needs a person
1832
+ // and, after each session, a report attached to the task.
1833
+
1834
+ const X = {
1835
+ monitors: path.join(COMPANION.home, "monitors.json"),
1836
+ approvals: path.join(COMPANION.home, "approvals.json"),
1837
+ monitorWakes: path.join(COMPANION.home, "monitor-wakes.json"),
1838
+ ops: path.join(COMPANION.home, "session-ops.log"),
1839
+ tickLock: path.join(COMPANION.home, "tick.lock"),
1840
+ notified: path.join(COMPANION.home, "notified.json"),
1841
+ };
1842
+ const MONITOR_TYPES = ["http", "port", "disk", "process", "file", "command"];
1843
+ // What an assistant may set up itself. A command monitor runs a shell command
1844
+ // every few minutes, so only the person at this computer can add one.
1845
+ const ASSISTANT_MONITOR_TYPES = ["http", "port", "disk", "process", "file"];
1846
+ const APPROVAL_TTL_MS = 24 * 3600 * 1000;
1847
+ const RUN_LIMIT_MS = 10 * 60 * 1000;
1848
+
1849
+ const localISO = d => new Date(d).toLocaleString();
1850
+ function ensureHome() { fs.mkdirSync(COMPANION.home, { recursive: true }); }
1851
+
1852
+ // ---- operation log (for session reports)
1853
+
1854
+ function logOp(command) {
1855
+ try {
1856
+ ensureHome();
1857
+ 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) };
1858
+ fs.appendFileSync(X.ops, JSON.stringify(line) + "\n");
1859
+ // Keep the last 500 lines.
1860
+ const lines = fs.readFileSync(X.ops, "utf8").split("\n").filter(Boolean);
1861
+ if (lines.length > 600) fs.writeFileSync(X.ops, lines.slice(-500).join("\n") + "\n");
1862
+ } catch (_) {}
1863
+ }
1864
+ function opsSince(t) {
1865
+ try {
1866
+ return fs.readFileSync(X.ops, "utf8").split("\n").filter(Boolean).map(l => JSON.parse(l)).filter(o => Date.parse(o.at) >= t);
1867
+ } catch (_) { return []; }
1868
+ }
1869
+
1870
+ // ---- desktop notifications (#8)
1871
+
1872
+ // Shows a notification on this computer. Best effort: a scheduled job with
1873
+ // nobody signed in has no desktop to show it on. WF_NOTIFY_LOG is for tests.
1874
+ function notify(title, body, onceKey) {
1875
+ const cfg = readJSON(CP.config, {}) || {};
1876
+ if (cfg.notifications === false) return;
1877
+ if (onceKey) {
1878
+ const seen = readJSON(X.notified, {}) || {};
1879
+ if (seen[onceKey] && Date.now() - seen[onceKey] < 12 * 3600 * 1000) return;
1880
+ seen[onceKey] = Date.now();
1881
+ try { ensureHome(); saveAtomic(X.notified, JSON.stringify(seen)); } catch (_) {}
1882
+ }
1883
+ title = String(title).slice(0, 80); body = String(body).slice(0, 240);
1884
+ if (process.env.WF_NOTIFY_LOG) { try { fs.appendFileSync(process.env.WF_NOTIFY_LOG, `${title} | ${body}\n`); } catch (_) {} return; }
1885
+ const { spawn } = require("child_process");
1886
+ const opts = { detached: true, stdio: "ignore", windowsHide: true, env: Object.assign({}, process.env, { WF_T: title, WF_B: body }) };
1887
+ // A missing notifier (no notify-send on a server) fails asynchronously:
1888
+ // without a listener that error would end the whole process.
1889
+ const quiet = child => { child.on("error", () => {}); child.unref(); };
1890
+ try {
1891
+ if (process.platform === "win32") {
1892
+ const ps = "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;" +
1893
+ "$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);" +
1894
+ "$x = $t.GetElementsByTagName('text'); $x.Item(0).AppendChild($t.CreateTextNode($env:WF_T)) > $null; $x.Item(1).AppendChild($t.CreateTextNode($env:WF_B)) > $null;" +
1895
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($t))";
1896
+ quiet(spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps], opts));
1897
+ } else if (process.platform === "darwin") {
1898
+ quiet(spawn("osascript", ["-e", "display notification (system attribute \"WF_B\") with title (system attribute \"WF_T\")"], opts));
1899
+ } else {
1900
+ quiet(spawn("notify-send", [title, body], opts));
1901
+ }
1902
+ } catch (_) {}
1903
+ }
1904
+
1905
+ // ---- monitors (#1)
1906
+
1907
+ function loadMonitors() { return readJSON(X.monitors, []) || []; }
1908
+ function saveMonitors(list) { ensureHome(); saveAtomic(X.monitors, JSON.stringify(list, null, 2)); }
1909
+
1910
+ // One check. Resolves { ok, detail, fingerprint? }; never throws.
1911
+ async function checkMonitor(m) {
1912
+ const target = String(m.target || "");
1913
+ try {
1914
+ if (m.type === "http") {
1915
+ const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 15000);
1916
+ try {
1917
+ const res = await fetch(target, { signal: ctl.signal, redirect: "follow" });
1918
+ const text = m.expectText ? await res.text() : "";
1919
+ if (res.status >= 400) return { ok: false, detail: `HTTP ${res.status}` };
1920
+ if (m.expectText && !text.includes(m.expectText)) return { ok: false, detail: `HTTP ${res.status}, but “${m.expectText}” isn't on the page` };
1921
+ return { ok: true, detail: `HTTP ${res.status}` };
1922
+ } finally { clearTimeout(timer); }
1923
+ }
1924
+ if (m.type === "port") {
1925
+ const [host, port] = [target.slice(0, target.lastIndexOf(":")), Number(target.slice(target.lastIndexOf(":") + 1))];
1926
+ return await new Promise(resolve => {
1927
+ const s = require("net").connect({ host, port, timeout: 5000 });
1928
+ s.on("connect", () => { s.destroy(); resolve({ ok: true, detail: `${target} open` }); });
1929
+ s.on("timeout", () => { s.destroy(); resolve({ ok: false, detail: `${target} timed out` }); });
1930
+ s.on("error", e => resolve({ ok: false, detail: `${target}: ${e.code || e.message}` }));
1931
+ });
1932
+ }
1933
+ if (m.type === "disk") {
1934
+ const st = fs.statfsSync(target);
1935
+ const freeGB = (st.bavail * st.bsize) / 1073741824;
1936
+ const min = Number(m.minFreeGB || 5);
1937
+ return { ok: freeGB >= min, detail: `${freeGB.toFixed(1)} GB free (needs ${min})` };
1938
+ }
1939
+ if (m.type === "process") {
1940
+ const { spawnSync } = require("child_process");
1941
+ const r = process.platform === "win32"
1942
+ ? spawnSync("tasklist", ["/FI", `IMAGENAME eq ${target}`, "/NH"], { encoding: "utf8", windowsHide: true })
1943
+ : spawnSync("pgrep", ["-f", target], { encoding: "utf8" });
1944
+ const running = process.platform === "win32" ? (r.stdout || "").toLowerCase().includes(target.toLowerCase()) : r.status === 0;
1945
+ return { ok: running, detail: running ? `${target} is running` : `${target} isn't running` };
1946
+ }
1947
+ if (m.type === "file") {
1948
+ if (!fs.existsSync(target)) return { ok: false, detail: `${target} is missing`, fingerprint: "missing" };
1949
+ const st = fs.statSync(target);
1950
+ return { ok: true, detail: `${target} changed ${localISO(st.mtime)}`, fingerprint: `${st.size}@${st.mtimeMs}` };
1951
+ }
1952
+ if (m.type === "command") {
1953
+ const { spawnSync } = require("child_process");
1954
+ const r = spawnSync(target, { shell: true, encoding: "utf8", timeout: 30000, windowsHide: true });
1955
+ const last = String((r.stdout || "") + (r.stderr || "")).trim().split("\n").pop() || "";
1956
+ return { ok: r.status === 0, detail: `exit ${r.status === null ? "timeout" : r.status}${last ? `: ${last.slice(0, 120)}` : ""}` };
1957
+ }
1958
+ return { ok: false, detail: `unknown monitor type ${m.type}` };
1959
+ } catch (e) {
1960
+ return { ok: false, detail: String(e.name === "AbortError" ? "timed out" : (e.cause && e.cause.code) || e.message).slice(0, 160) };
1961
+ }
1962
+ }
1963
+
1964
+ // Runs the monitors that are due. A change of state posts a comment on the
1965
+ // monitor's task, shows a notification, and (with wake on) queues a session.
1966
+ async function runMonitors({ force = false } = {}) {
1967
+ const list = loadMonitors();
1968
+ if (!list.length) return [];
1969
+ const changes = [];
1970
+ for (const m of list) {
1971
+ const due = force || !m.checkedAt || Date.now() - Date.parse(m.checkedAt) >= Math.max(2, m.every || 5) * 60000 - 15000;
1972
+ if (!due) continue;
1973
+ const r = await checkMonitor(m);
1974
+ const before = m.state;
1975
+ m.checkedAt = new Date().toISOString();
1976
+ // A file monitor reports a change of the file itself, not up and down.
1977
+ const changed = m.type === "file"
1978
+ ? before && before.fingerprint !== undefined && r.fingerprint !== before.fingerprint
1979
+ : before ? before.ok !== r.ok : !r.ok;
1980
+ const prevSince = m.since;
1981
+ if (!before || changed) m.since = m.checkedAt;
1982
+ m.state = { ok: r.ok, detail: r.detail, fingerprint: r.fingerprint };
1983
+ if (changed) changes.push({ m, r, before, prevSince });
1984
+ }
1985
+ saveMonitors(list);
1986
+ for (const { m, r, before, prevSince } of changes) {
1987
+ const text = m.type === "file"
1988
+ ? `📄 Monitor “${m.name}”: ${r.detail}.`
1989
+ : r.ok ? `✅ Monitor “${m.name}” is OK again: ${r.detail}${before && prevSince ? ` (failing since ${localISO(prevSince)})` : ""}.`
1990
+ : `⚠️ Monitor “${m.name}” FAILED: ${r.detail}.`;
1991
+ notify(`WorkFlow · ${m.name}`, text.replace(/^\S+ /, ""));
1992
+ if (m.task) {
1993
+ try { await send("add_comment", { taskID: m.task, text }); } catch (e) { logWake(`monitor ${m.name}: couldn't comment: ${e.message}`); }
1994
+ }
1995
+ if (m.wake && m.task) {
1996
+ const q = readJSON(X.monitorWakes, []) || [];
1997
+ 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}` });
1998
+ saveAtomic(X.monitorWakes, JSON.stringify(q.slice(-20)));
1999
+ }
2000
+ }
2001
+ return changes;
2002
+ }
2003
+
2004
+ function monitorLine(m) {
2005
+ const s = m.state ? `${m.state.ok ? "OK" : "FAILED"}: ${m.state.detail} (checked ${localISO(m.checkedAt)})` : "not checked yet";
2006
+ return `${m.name} [${m.type} ${m.target}] every ${m.every || 5} min${m.wake ? ", wakes Claude" : ""}${m.taskTitle ? `, task “${m.taskTitle}”` : ""}\n ${s}`;
2007
+ }
2008
+
2009
+ // Adds or replaces a monitor. `by` is "person" (the command line) or
2010
+ // "assistant" (the MCP tool), which may not add command monitors.
2011
+ function addMonitor(spec, by) {
2012
+ const name = String(spec.name || "").trim();
2013
+ if (!/^[\w .-]{1,40}$/.test(name)) throw new Error("Give the monitor a short name (letters, digits, spaces, . _ -).");
2014
+ if (!MONITOR_TYPES.includes(spec.type)) throw new Error(`Type must be one of: ${MONITOR_TYPES.join(", ")}.`);
2015
+ if (by === "assistant" && !ASSISTANT_MONITOR_TYPES.includes(spec.type)) {
2016
+ 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 \"…\"");
2017
+ }
2018
+ if (!spec.target) throw new Error("Say what to check (a URL, host:port, a folder, a process name, a file, or a command).");
2019
+ if (spec.type === "http" && !/^https?:\/\//i.test(spec.target)) throw new Error("An http monitor needs a URL starting http:// or https://.");
2020
+ if (spec.type === "port" && !/^.+:\d{1,5}$/.test(spec.target)) throw new Error("A port monitor needs host:port.");
2021
+ const list = loadMonitors().filter(m => m.name.toLowerCase() !== name.toLowerCase());
2022
+ const m = { name, type: spec.type, target: String(spec.target), every: Math.min(1440, Math.max(2, Number(spec.every) || 5)),
2023
+ wake: !!spec.wake, task: spec.task || "", taskTitle: spec.taskTitle || "", by, addedAt: new Date().toISOString() };
2024
+ if (spec.minFreeGB) m.minFreeGB = Number(spec.minFreeGB);
2025
+ if (spec.expectText) m.expectText = String(spec.expectText);
2026
+ list.push(m);
2027
+ if (list.length > 30) throw new Error("That's 30 monitors already; remove one first.");
2028
+ saveMonitors(list);
2029
+ return m;
2030
+ }
2031
+
2032
+ function removeMonitor(name) {
2033
+ const list = loadMonitors();
2034
+ const kept = list.filter(m => m.name.toLowerCase() !== String(name).toLowerCase());
2035
+ if (kept.length === list.length) throw new Error(`No monitor called “${name}”.`);
2036
+ saveMonitors(kept);
2037
+ }
2038
+
2039
+ // ---- approvals (#3)
2040
+
2041
+ function loadApprovals() { return readJSON(X.approvals, []) || []; }
2042
+ function saveApprovals(list) { ensureHome(); saveAtomic(X.approvals, JSON.stringify(list.slice(-100), null, 2)); }
2043
+ function approvalCode() {
2044
+ const abc = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // nothing that reads as another letter
2045
+ return Array.from(require("crypto").randomBytes(4)).map(b => abc[b % abc.length]).join("");
2046
+ }
2047
+
2048
+ // The person's answer to an approval, from the task's thread: "approve K7Q2"
2049
+ // or "decline K7Q2" in a comment written after the request by a person, never
2050
+ // an assistant. Returns { decision, by, at, commentID } or null.
2051
+ function approvalAnswer(a, snap) {
2052
+ const t = (snap.tasks || []).find(x => x.id === a.taskID);
2053
+ if (!t) return null;
2054
+ const shared = new Set((snap.projects || []).filter(p => p.isShared).map(p => p.name));
2055
+ if (t.project && shared.has(t.project)) return null;
2056
+ const re = new RegExp(`(^|[^A-Za-z0-9])(approve|approved|decline|declined|deny|no)\\s+${a.code}(?![A-Za-z0-9])`, "i");
2057
+ for (const c of (t.comments || []).slice().reverse()) {
2058
+ if (isAgentID(c.authorID)) continue;
2059
+ if (Date.parse(c.timestamp) < Date.parse(a.requestedAt)) break;
2060
+ const m = re.exec(c.text || "");
2061
+ if (m) return { decision: /^approv/i.test(m[2]) ? "approved" : "declined", by: c.author || "?", at: c.timestamp, commentID: c.id };
2062
+ }
2063
+ return null;
2064
+ }
2065
+
2066
+ // Brings each pending approval up to date with the snapshot.
2067
+ function refreshApprovals(snap) {
2068
+ const list = loadApprovals();
2069
+ let changed = false;
2070
+ const decided = [];
2071
+ for (const a of list) {
2072
+ if (a.status !== "pending") continue;
2073
+ if (Date.now() > Date.parse(a.expiresAt)) { a.status = "expired"; changed = true; continue; }
2074
+ const ans = snap ? approvalAnswer(a, snap) : null;
2075
+ if (ans) { Object.assign(a, { status: ans.decision, decidedBy: ans.by, decidedAt: ans.at, decisionComment: ans.commentID }); changed = true; decided.push(a); }
2076
+ }
2077
+ if (changed) saveApprovals(list);
2078
+ return decided;
2079
+ }
2080
+
2081
+ function findApproval(code) {
2082
+ const a = loadApprovals().find(x => x.code === String(code || "").trim().toUpperCase());
2083
+ if (!a) throw new Error(`No approval ${code} on this computer.`);
2084
+ return a;
2085
+ }
2086
+
2087
+ // Runs an approved command, exactly as it was when the person approved it.
2088
+ function runApproved(a) {
2089
+ const { spawnSync } = require("child_process");
2090
+ const started = Date.now();
2091
+ const r = spawnSync(a.command, { shell: true, encoding: "utf8", timeout: RUN_LIMIT_MS, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
2092
+ cwd: a.cwd && fs.existsSync(a.cwd) ? a.cwd : os.homedir() });
2093
+ const output = String((r.stdout || "") + (r.stderr || "")).trim();
2094
+ return { exit: r.status === null ? "timed out" : r.status, seconds: Math.round((Date.now() - started) / 1000), output: output.slice(-6000) };
2095
+ }
2096
+
2097
+ // ---- inbox folder (#7)
2098
+
2099
+ function inboxFolder(cfg) { return cfg && cfg.inboxFolder ? cfg.inboxFolder : null; }
2100
+
2101
+ // Files dropped in the folder (or a subfolder named after a project) become
2102
+ // tasks with the file attached, then move to "Filed". Up to 5 a tick; a file
2103
+ // still being written (changed in the last 10 s) waits for the next one.
2104
+ async function runInbox(cfg) {
2105
+ const root = inboxFolder(cfg);
2106
+ if (!root || !fs.existsSync(root)) return [];
2107
+ const done = [];
2108
+ const skip = n => n.startsWith(".") || /\.(tmp|part|crdownload|download)$/i.test(n) || n === "Filed" || /^desktop\.ini$|^thumbs\.db$/i.test(n);
2109
+ const candidates = [];
2110
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
2111
+ if (skip(entry.name)) continue;
2112
+ if (entry.isFile()) candidates.push({ file: path.join(root, entry.name), project: cfg.inboxProject || "" });
2113
+ else if (entry.isDirectory()) {
2114
+ for (const sub of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
2115
+ if (sub.isFile() && !skip(sub.name)) candidates.push({ file: path.join(root, entry.name, sub.name), project: entry.name });
2116
+ }
2117
+ }
2118
+ }
2119
+ for (const c of candidates.slice(0, 5)) {
2120
+ const st = fs.statSync(c.file);
2121
+ if (Date.now() - st.mtimeMs < 10000) continue;
2122
+ const name = path.basename(c.file);
2123
+ try {
2124
+ const created = await send("create_task", { title: name.replace(/\.[^.]+$/, ""), project: c.project || undefined,
2125
+ notes: `Dropped into ${root} on ${os.hostname()}.`, assignee: cfg.inboxAssign ? AGENT.name : undefined, hasDueDate: false });
2126
+ if (created.status !== "applied" || !created.taskID) throw new Error(created.message || "the task wasn't created");
2127
+ const staged = await stageFile(c.file);
2128
+ const att = await sendWithFile("attach_file", { taskID: created.taskID, displayName: name }, staged, 15000);
2129
+ if (att.status === "failed" || att.status === "rejected") throw new Error(att.message || "the file wasn't attached");
2130
+ const filed = path.join(root, "Filed", new Date().toISOString().slice(0, 10));
2131
+ fs.mkdirSync(filed, { recursive: true });
2132
+ let dest = path.join(filed, name);
2133
+ if (fs.existsSync(dest)) dest = path.join(filed, `${Date.now()}-${name}`);
2134
+ fs.renameSync(c.file, dest);
2135
+ done.push(name);
2136
+ logWake(`inbox: “${name}” → task ${created.taskID}`);
2137
+ } catch (e) {
2138
+ logWake(`inbox: “${name}” not filed: ${e.message}`);
2139
+ }
2140
+ }
2141
+ if (done.length) notify("WorkFlow", `${done.length === 1 ? `“${done[0]}” is` : `${done.length} files are`} now in WorkFlow.`);
2142
+ return done;
2143
+ }
2144
+
2145
+ // ---- the tick: everything above, once, without overlapping itself
2146
+
2147
+ async function tickExtras() {
2148
+ const cfg = readJSON(CP.config, null);
2149
+ if (!cfg) return;
2150
+ ensureHome();
2151
+ // One tick at a time; a holder older than 10 minutes is stale.
2152
+ try { const fd = fs.openSync(X.tickLock, "wx"); fs.closeSync(fd); }
2153
+ catch (_) {
2154
+ let age = 0; try { age = Date.now() - fs.statSync(X.tickLock).mtimeMs; } catch (_) {}
2155
+ if (age < 10 * 60000) return;
2156
+ try { fs.writeFileSync(X.tickLock, ""); } catch (_) { return; }
2157
+ }
2158
+ try {
2159
+ await runMonitors().catch(e => logWake(`monitors: ${e.message}`));
2160
+ await runInbox(cfg).catch(e => logWake(`inbox: ${e.message}`));
2161
+ const decided = refreshApprovals(cloudSnap);
2162
+ for (const a of decided) notify(`WorkFlow · approval ${a.code}`, `${a.decidedBy} ${a.status} “${a.action}”.`);
2163
+ } finally { try { fs.unlinkSync(X.tickLock); } catch (_) {} }
2164
+ }
2165
+
2166
+ // Makes sure the 2-minute job exists (monitors and the inbox folder need it
2167
+ // even with waking off). Respects WF_NO_SCHEDULE and --no-schedule.
2168
+ function ensureTick() {
2169
+ if (argv.includes("--no-schedule") || process.env.WF_NO_SCHEDULE) return "not scheduled (WF_NO_SCHEDULE)";
2170
+ return installWakeSchedule();
2171
+ }
2172
+
2173
+ // ---- session report (#4)
2174
+
2175
+ async function attachReport({ items, started, r, wrote }) {
2176
+ const cfg = readJSON(CP.config, {}) || {};
2177
+ const first = items.find(i => i.kind === "task");
2178
+ if (cfg.reports === false || !first || !wrote) return;
2179
+ const ops = opsSince(started);
2180
+ const ran = loadApprovals().filter(a => a.ranAt && Date.parse(a.ranAt) >= started);
2181
+ const final = r.out.trim().split("\n").slice(-40).join("\n").slice(-3000);
2182
+ const md = [
2183
+ `# Session report: ${AGENT.name} on ${os.hostname()}`,
2184
+ "",
2185
+ `- Started: ${localISO(started)}`,
2186
+ `- Took: ${Math.max(1, Math.round((Date.now() - started) / 60000))} min`,
2187
+ `- Outcome: ${r.timedOut ? "stopped at the time limit" : r.code === 0 ? "finished" : `ended early (exit ${r.code})`}`,
2188
+ `- Asked about: ${items.map(i => `${i.title} (${i.why})`).join("; ")}`,
2189
+ "",
2190
+ "## What it changed in WorkFlow",
2191
+ ...(ops.length ? ops.map(o => `- ${localISO(o.at).split(", ").pop()} ${o.op}${o.what ? `: ${o.what}` : ""}`) : ["- nothing"]),
2192
+ ...(ran.length ? ["", "## Commands it ran (approved)", ...ran.map(a => `- \`${a.command}\`: exit ${a.result && a.result.exit} (approved by ${a.decidedBy})`)] : []),
2193
+ "",
2194
+ "## Its last words",
2195
+ "",
2196
+ "```",
2197
+ final || "(nothing)",
2198
+ "```",
2199
+ "",
2200
+ ].join("\n");
2201
+ const file = path.join(os.tmpdir(), `session-report-${Date.now()}.md`);
2202
+ fs.writeFileSync(file, md);
2203
+ try {
2204
+ const staged = await stageFile(file);
2205
+ const stamp = new Date(started).toLocaleString([], { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
2206
+ await sendWithFile("attach_file", { taskID: first.id, displayName: `Session report ${stamp}.md` }, staged, 15000);
2207
+ } catch (e) { logWake(`report not attached: ${e.message}`); }
2208
+ finally { try { fs.unlinkSync(file); } catch (_) {} }
2209
+ }
2210
+
2211
+ // ---- command line
2212
+
2213
+ function monitorCLI() {
2214
+ const sub = argv[argv.indexOf("monitor") + 1];
2215
+ if (sub === "list" || !sub) {
2216
+ const list = loadMonitors();
2217
+ 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");
2218
+ return Promise.resolve(0);
2219
+ }
2220
+ if (sub === "remove") { removeMonitor(argv[argv.indexOf("remove") + 1]); console.log("Removed."); return Promise.resolve(0); }
2221
+ if (sub === "check") {
2222
+ return (async () => {
2223
+ for (const m of loadMonitors()) { const r = await checkMonitor(m); console.log(`${m.name}: ${r.ok ? "OK" : "FAILED"}: ${r.detail}`); }
2224
+ return 0;
2225
+ })();
2226
+ }
2227
+ if (sub === "add") {
2228
+ return (async () => {
2229
+ const type = MONITOR_TYPES.find(t => argv.includes(`--${t}`));
2230
+ if (!type) throw new Error(`Say what to check: --http URL, --port host:port, --disk PATH, --process NAME, --file PATH or --command "…"`);
2231
+ let task = "", taskTitle = "";
2232
+ if (arg("task")) {
2233
+ await cloud.load(true);
2234
+ const t = findTask(arg("task"));
2235
+ task = t.id; taskTitle = t.title;
2236
+ }
2237
+ const m = addMonitor({ name: arg("name"), type, target: arg(type), every: arg("every"), wake: argv.includes("--wake"),
2238
+ task, taskTitle, minFreeGB: arg("min-gb"), expectText: arg("expect") }, "person");
2239
+ const r = await checkMonitor(m);
2240
+ 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" : ""}.`);
2241
+ console.log(`Schedule: ${ensureTick()}.`);
2242
+ return 0;
2243
+ })();
2244
+ }
2245
+ throw new Error("monitor list | add | remove NAME | check");
2246
+ }
2247
+
2248
+ function setFlag(key, on, sayOn, sayOff) {
2249
+ if (!fs.existsSync(CP.config)) { console.error(signInHelp("Not signed in yet.")); return 2; }
2250
+ updateConfig(cfg => { cfg[key] = on; });
2251
+ console.log(on ? sayOn : sayOff);
2252
+ return 0;
2253
+ }
2254
+
2255
+ async function inboxCLI(on) {
2256
+ if (!fs.existsSync(CP.config)) { console.error(signInHelp("Not signed in yet.")); return 2; }
2257
+ if (!on) { updateConfig(cfg => { delete cfg.inboxFolder; }); console.log("The inbox folder is off. Files in it stay where they are."); return 0; }
2258
+ const folder = path.resolve(String(arg("folder") || path.join(os.homedir(), "WorkFlow Inbox")).replace(/^~/, os.homedir()));
2259
+ fs.mkdirSync(folder, { recursive: true });
2260
+ updateConfig(cfg => { cfg.inboxFolder = folder; cfg.inboxProject = arg("project") || ""; cfg.inboxAssign = argv.includes("--assign"); });
2261
+ 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()}.`);
2262
+ return 0;
2263
+ }
2264
+
2265
+ // ------------------------------------------------------------------ doctor (1.8.1)
2266
+ //
2267
+ // Checks the whole chain on this computer in one go and says how to fix
2268
+ // anything that's wrong: Node, the iCloud sign-in, whether WorkFlow is
2269
+ // publishing and answering, the name, the version, the scheduled jobs,
2270
+ // Claude Code, and what the last session did. `fromTool` skips the checks
2271
+ // that would be odd from inside a Claude session (asking Claude Code about
2272
+ // itself).
2273
+ async function doctorReport({ fromTool = false } = {}) {
2274
+ const npx = "npx -y @opticell/workflow-companion@latest";
2275
+ const lines = [`WorkFlow companion ${SERVER_INFO.version} on ${os.hostname()}, as "${AGENT.name}"`, ""];
2276
+ let problems = 0, warnings = 0;
2277
+ const ok = l => lines.push(` ✓ ${l}`);
2278
+ const bad = (l, fix) => { problems++; lines.push(` ✗ ${l}`); if (fix) lines.push(` fix: ${fix}`); };
2279
+ const warn = (l, fix) => { warnings++; lines.push(` ! ${l}`); if (fix) lines.push(` ${fix}`); };
2280
+ const done = () => {
2281
+ lines.push("", problems ? `${problems} problem(s)${warnings ? `, ${warnings} warning(s)` : ""}. Fix the ✗ lines first.`
2282
+ : warnings ? `Working, with ${warnings} warning(s).` : "All good.");
2283
+ return { text: lines.join("\n"), problems, warnings };
2284
+ };
2285
+ const ago = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? "just now" : m < 90 ? `${m} min ago` : m < 2880 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} days ago`; };
2286
+ const { spawnSync } = require("child_process");
2287
+
2288
+ const major = parseInt(process.versions.node, 10);
2289
+ major >= 18 ? ok(`Node.js ${process.versions.node}`) : bad(`Node.js ${process.versions.node} is too old (18 or later)`, "install the LTS from https://nodejs.org");
2290
+ if (!CLOUD) { warn("this is the Mac's own bridge, not a companion", "on the Mac, check Settings → Agent Bridge, or run: wf state"); return done(); }
2291
+
2292
+ const cfg = readJSON(CP.config, null);
2293
+ if (!cfg || !fs.existsSync(CP.token)) { bad("not signed in on this computer", `${npx} setup`); return done(); }
2294
+ try {
2295
+ await cloud.call("public/users/caller");
2296
+ ok(`iCloud sign-in works (${cfg.env || COMPANION.env})`);
2297
+ } catch (e) {
2298
+ if (e instanceof SignInNeeded) bad("the iCloud sign-in has ended (Apple ends one left unused for 9 to 14 hours)", `${npx} setup`);
2299
+ else bad(`can't reach iCloud: ${String(e.message).split("\n")[0]}`, "check this computer's internet connection, then run doctor again");
2300
+ return done();
2301
+ }
2302
+
2303
+ try { await cloud.load(true); } catch (e) { bad(`couldn't read WorkFlow's snapshot: ${e.message}`); return done(); }
2304
+ if (!cloudSnap) {
2305
+ bad("WorkFlow hasn't published anything for the companion yet",
2306
+ "switch it on in WorkFlow: iPhone/iPad Settings → Windows Companion → Answer the Companion, or Mac Settings → Agent Bridge → Cloud Bridge");
2307
+ } else {
2308
+ const age = Date.now() - Date.parse(cloudSnap.generatedAt);
2309
+ const line = `${cloudSnap.tasks.length} tasks, published ${ago(Date.parse(cloudSnap.generatedAt))}; answering: ${cloud.macLine()}`;
2310
+ age < 6 * 3600 * 1000 ? ok(line) : warn(line, "nothing has changed in WorkFlow for a while, or the device that answers is asleep or closed: open WorkFlow on it");
2311
+ const clash = nameClash(AGENT.name);
2312
+ clash ? bad(`"${AGENT.name}" is also the name of Claude on ${clash.host}: both would act on @${AGENT.name}`, `${npx} setup --name <another name>`)
2313
+ : ok(`the name "${AGENT.name}" is this computer's alone`);
2314
+ }
2315
+
2316
+ if (!fromTool) {
2317
+ const started = Date.now();
2318
+ try {
2319
+ const r = await send("ping", {}, 30000);
2320
+ r.status === "applied" ? ok(`a test command was applied by WorkFlow in ${Math.max(1, Math.round((Date.now() - started) / 1000))} s`)
2321
+ : warn("a test command got no answer within 30 s", "WorkFlow on the answering device may be asleep or closed. Commands wait safely and run when it next looks; an iPhone can take minutes");
2322
+ } catch (e) { warn(`couldn't send a test command: ${e.message}`); }
2323
+ }
2324
+
2325
+ const rel = await latestRelease(true);
2326
+ if (!rel) warn("couldn't check npm for a newer release");
2327
+ else if (newerVersion(rel.version, SERVER_INFO.version)) warn(`version ${SERVER_INFO.version}; ${rel.version} is out`, `${npx} update (and restart Claude Code)`);
2328
+ else ok(`version ${SERVER_INFO.version} is the latest`);
2329
+ const saved = versionOfScript(SAVED_SCRIPT);
2330
+ if (saved && rel && newerVersion(rel.version, saved)) warn(`the scheduled jobs run ${saved}`, cfg.autoUpdate === false ? `auto-update is off: ${npx} update` : "they update themselves within 6 hours, or now with: " + `${npx} update`);
2331
+
2332
+ const scheduled = name => {
2333
+ if (process.platform === "win32") return spawnSync("schtasks", ["/Query", "/TN", name], { encoding: "utf8", windowsHide: true }).status === 0;
2334
+ const r = spawnSync("crontab", ["-l"], { encoding: "utf8" });
2335
+ return r.status === 0 && r.stdout.includes(name === TASK_NAME ? "workflow-companion keepalive" : "workflow-companion wake");
2336
+ };
2337
+ let lastKeepAlive = "";
2338
+ try { lastKeepAlive = fs.readFileSync(KEEPALIVE_LOG, "utf8").trim().split("\n").pop(); } catch (_) {}
2339
+ scheduled(TASK_NAME) ? ok(`keep-alive scheduled${lastKeepAlive ? `; last run ${lastKeepAlive.split(" ")[0].slice(0, 16).replace("T", " ")}: ${lastKeepAlive.split(" ").slice(1).join(" ")}` : ""}`)
2340
+ : warn("the keep-alive isn't scheduled, so the sign-in can end while Claude isn't running", `${npx} setup (it schedules it)`);
2341
+
2342
+ if (cfg.wakeEnabled) {
2343
+ scheduled(WAKE.taskName) ? ok("waking is on and scheduled every 2 minutes") : bad("waking is on but its scheduled job is missing", `${npx} wake-on`);
2344
+ const bin = cfg.claudeBin || "claude";
2345
+ const v = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 20000, windowsHide: true, shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(bin) });
2346
+ v.status === 0 ? ok(`Claude Code runs: ${String(v.stdout).trim().split("\n")[0]}`)
2347
+ : bad(`Claude Code at ${bin} doesn't run`, `reinstall or sign in to Claude Code, then: ${npx} wake-on`);
2348
+ let log = [];
2349
+ try { log = fs.readFileSync(WAKE.log, "utf8").trim().split("\n"); } catch (_) {}
2350
+ const last = log.filter(l => / session exited /.test(l)).pop();
2351
+ if (!last) ok("no sessions yet");
2352
+ else {
2353
+ const when = Date.parse(last.split(" ")[0]);
2354
+ const code = (/session exited (-?\d+)/.exec(last) || [])[1];
2355
+ code === "0" && !/time limit/.test(last) ? ok(`last session ${ago(when)} finished normally`)
2356
+ : warn(`last session ${ago(when)} didn't finish normally: ${last.split(" ").slice(1).join(" ").slice(0, 160)}`, `see ${WAKE.log}`);
2357
+ }
2358
+ if (log.slice(-30).some(l => /SIGN-IN ENDED/.test(l))) warn("the sign-in ended recently (it works now)", "if this PC sleeps for long, leave it awake or check it each morning");
2359
+ } else {
2360
+ warn("waking is off: work waits until you ask Claude on this computer", `${npx} wake-on`);
2361
+ }
2362
+
2363
+ if (!fromTool) {
2364
+ const bin = cfg.claudeBin || "claude";
2365
+ const list = spawnSync(bin, ["mcp", "list"], { encoding: "utf8", timeout: 60000, windowsHide: true, shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(bin) });
2366
+ const text = String(list.stdout || "");
2367
+ if (list.status !== 0 && !text) warn("couldn't ask Claude Code which tools it has", `if it isn't installed: ${claudeCodeCommand()}`);
2368
+ else if (!/workflow/i.test(text)) bad("Claude Code doesn't have the companion", claudeCodeCommand());
2369
+ else if (!/@latest/.test(text.split("\n").find(l => /^workflow/i.test(l)) || "")) warn("Claude Code runs a pinned version of the companion", `claude mcp remove workflow -s user, then: ${claudeCodeCommand()}`);
2370
+ else ok("Claude Code has the companion, on @latest");
2371
+ }
2372
+
2373
+ const failing = loadMonitors().filter(m => m.state && !m.state.ok);
2374
+ if (loadMonitors().length) failing.length ? warn(`monitors failing: ${failing.map(m => `${m.name} (${m.state.detail})`).join(", ")}`) : ok(`${loadMonitors().length} monitor(s), all OK`);
2375
+ const pending = loadApprovals().filter(a => a.status === "pending");
2376
+ if (pending.length) warn(`${pending.length} approval(s) waiting for you: ${pending.map(a => `${a.code} on “${a.taskTitle}”`).join(", ")}`);
2377
+ if (cfg.inboxFolder) fs.existsSync(cfg.inboxFolder) ? ok(`inbox folder: ${cfg.inboxFolder}`) : bad(`the inbox folder ${cfg.inboxFolder} is missing`, `${npx} inbox-on --folder "<path>"`);
2378
+ return done();
2379
+ }
2380
+
1688
2381
  // ------------------------------------------------------------------ versions (1.6.1)
1689
2382
 
1690
2383
  // Each companion says which version it runs, and keeps itself current.
@@ -1911,6 +2604,7 @@ const HELP = `WorkFlow companion ${SERVER_INFO.version}
1911
2604
  workflow-companion run the MCP server (what Claude starts)
1912
2605
  workflow-companion setup sign this computer in to iCloud for WorkFlow (--name, --takeover, --no-browser)
1913
2606
  workflow-companion status which device answers, how fresh is the snapshot
2607
+ workflow-companion doctor check everything on this computer and say how to fix what's wrong
1914
2608
  workflow-companion keepalive renew the sign-in (what the scheduled job runs)
1915
2609
  workflow-companion update update the scheduled jobs' copy now, and say how to keep Claude Code current
1916
2610
  workflow-companion auto-update on|off whether the scheduled jobs update themselves (on by default)
@@ -1918,21 +2612,42 @@ const HELP = `WorkFlow companion ${SERVER_INFO.version}
1918
2612
  (--allow-tools, --model, --workdir, --max-minutes, --claude, --skip-existing)
1919
2613
  workflow-companion wake-off stop doing that; work waits until you ask
1920
2614
  workflow-companion wake one check of the queue (what the scheduled job runs)
2615
+ workflow-companion monitor list | check | remove NAME
2616
+ workflow-companion monitor add --name N (--http URL | --port HOST:PORT | --disk PATH --min-gb G |
2617
+ --process NAME | --file PATH | --command "…") [--task T] [--every MIN] [--wake]
2618
+ checks this computer runs without using Claude; changes are posted on the task
2619
+ workflow-companion approvals on|off let an assistant run a command once you reply "approve CODE" (off by default)
2620
+ workflow-companion inbox-on [--folder PATH] [--project P] [--assign] files dropped in the folder become tasks
2621
+ workflow-companion inbox-off
2622
+ workflow-companion notifications on|off notices on this computer (new work, monitors, sign-in ended)
2623
+ workflow-companion reports on|off attach a short report to the task after each session (on by default)
1921
2624
  workflow-companion signout forget the sign-in on this computer
1922
2625
  workflow-companion --print-config
1923
2626
 
1924
2627
  Needs WorkFlow 5.2+ on an iPhone, iPad or Mac, with the companion switched on in its Settings.
2628
+ Install in one line: see https://github.com/TeamDzX/workflow-companion#install
1925
2629
  `;
1926
2630
 
1927
2631
  if (argv.includes("--print-config")) printConfig();
1928
2632
  else if (argv.includes("--help") || verb === "help") process.stdout.write(HELP);
1929
2633
  else if (verb === "setup") setup().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
2634
+ 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; });
1930
2635
  else if (verb === "status") cloudStateText().then(t => console.log(t), e => { console.error(e.message || e); process.exitCode = 1; });
1931
2636
  else if (verb === "keepalive") keepAlive().then(c => { process.exitCode = c; });
1932
2637
  else if (verb === "update") updateNow().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
1933
2638
  else if (verb === "auto-update") process.exitCode = setAutoUpdate(argv[argv.indexOf("auto-update") + 1] !== "off");
1934
2639
  else if (verb === "wake") wake().then(c => { process.exitCode = c; }, e => { logWake(`failed: ${e.message || e}`); process.exitCode = 1; });
1935
2640
  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; });
2641
+ else if (verb === "monitor") monitorCLI().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
2642
+ else if (verb === "approvals") process.exitCode = setFlag("approvalsEnabled", argv[argv.indexOf("approvals") + 1] === "on",
2643
+ "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",
2644
+ "Approvals are off. Requests are refused and approved ones won't run.");
2645
+ 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; });
2646
+ else if (verb === "notifications") process.exitCode = setFlag("notifications", argv[argv.indexOf("notifications") + 1] !== "off",
2647
+ "Notifications are on.", "Notifications are off.");
2648
+ else if (verb === "reports") process.exitCode = setFlag("reports", argv[argv.indexOf("reports") + 1] !== "off",
2649
+ "Session reports are on: each session attaches a short report to its task.", "Session reports are off.");
2650
+ else if (verb === "notify-test") { notify("WorkFlow companion", `Notifications work on ${os.hostname()}.`); console.log("Sent a test notification."); }
1936
2651
  else if (verb === "signout") {
1937
2652
  removeSchedule();
1938
2653
  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.1",
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": [