@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.
- package/README.md +95 -2
- package/index.js +720 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# WorkFlow Companion
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@opticell/workflow-companion)
|
|
4
|
+
[](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
|
-
|
|
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
|
|
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.
|
|
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) {
|
|
1537
|
-
|
|
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.
|
|
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": [
|