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