@opticell/workflow-companion 0.0.0-stage → 1.5.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/LICENSE +10 -0
- package/README.md +120 -2
- package/index.js +1175 -0
- package/package.json +27 -3
package/index.js
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// WorkFlow MCP server — lets Claude Desktop, Claude Code or any MCP client
|
|
3
|
+
// drive WorkFlow through the Agent Bridge. Speaks MCP (JSON-RPC 2.0) over
|
|
4
|
+
// stdio. Plain Node, no dependencies, one file — it ships inside the app and
|
|
5
|
+
// is written into the bridge folder as tools/index.js, and packed into
|
|
6
|
+
// WorkFlow.mcpb for one-click install in Claude Desktop.
|
|
7
|
+
//
|
|
8
|
+
// Reads come from snapshot.json (the app's own export, rewritten after every
|
|
9
|
+
// save). Writes are JSON command files dropped into inbox/; the app applies
|
|
10
|
+
// them and answers with a receipt in outbox/. Nothing here touches the
|
|
11
|
+
// database, and the app's Settings toggles and Command History govern every
|
|
12
|
+
// write exactly as they do for the command line.
|
|
13
|
+
//
|
|
14
|
+
// Where the bridge is, in order: $WF_BRIDGE (baked into the bundle at export),
|
|
15
|
+
// the app's location.json pointer, the app's container.
|
|
16
|
+
//
|
|
17
|
+
// iCloud mode (5.2) — the WorkFlow companion, published to npm as
|
|
18
|
+
// @opticell/workflow-companion for Claude on Windows or Linux. Same tools,
|
|
19
|
+
// but commands and receipts travel as records in the user's own private
|
|
20
|
+
// iCloud (zone AgentBridge) and WorkFlow on their Mac applies them. The Mac
|
|
21
|
+
// is the only applier; this never writes WorkFlow's own records. Chosen with
|
|
22
|
+
// --cloud, WF_MODE=cloud, or automatically off the Mac. `setup` signs in.
|
|
23
|
+
|
|
24
|
+
"use strict";
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const os = require("os");
|
|
28
|
+
|
|
29
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
30
|
+
const SERVER_INFO = { name: "workflow", version: "1.5.0" };
|
|
31
|
+
// Who the agent is in WorkFlow: the assignee its queue follows, the @name
|
|
32
|
+
// that calls it, the signature on what it writes. On the Mac it's Claude.
|
|
33
|
+
// A companion goes by the name chosen at setup (default "Companion"), so a
|
|
34
|
+
// task can be meant for the server's Claude rather than the Mac's.
|
|
35
|
+
let AGENT = { name: "Claude", authorID: "agent.claude" };
|
|
36
|
+
|
|
37
|
+
// ------------------------------------------------------------------ bridge
|
|
38
|
+
|
|
39
|
+
function containerRoot() {
|
|
40
|
+
return path.join(os.homedir(), "Library/Containers/mac.TeamDZX.worflow/Data/Library/Application Support/AgentBridge");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bridgeRoot() {
|
|
44
|
+
if (process.env.WF_BRIDGE) return process.env.WF_BRIDGE;
|
|
45
|
+
try {
|
|
46
|
+
const pointer = JSON.parse(fs.readFileSync(path.join(containerRoot(), "location.json"), "utf8"));
|
|
47
|
+
if (pointer.root && fs.existsSync(pointer.root)) return pointer.root;
|
|
48
|
+
} catch (_) { /* no pointer: the bridge is still inside the container */ }
|
|
49
|
+
return containerRoot();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ROOT = bridgeRoot();
|
|
53
|
+
const P = {
|
|
54
|
+
snapshot: path.join(ROOT, "snapshot.json"),
|
|
55
|
+
state: path.join(ROOT, "state.json"),
|
|
56
|
+
inbox: path.join(ROOT, "inbox"),
|
|
57
|
+
outbox: path.join(ROOT, "outbox"),
|
|
58
|
+
attachments: path.join(ROOT, "attachments"),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function readJSON(file, fallback) {
|
|
62
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (_) { return fallback; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function snapshot() {
|
|
66
|
+
if (CLOUD) {
|
|
67
|
+
if (!cloudSnap) throw new Error("No snapshot in iCloud yet. Switch the companion on in WorkFlow: WorkFlow → Settings → Windows Companion → Answer the Companion (iPhone, iPad), or Settings → Agent Bridge → Cloud Bridge (Mac).");
|
|
68
|
+
return cloudSnap;
|
|
69
|
+
}
|
|
70
|
+
const s = readJSON(P.snapshot, null);
|
|
71
|
+
if (!s) throw new Error(`No snapshot at ${P.snapshot}. Is WorkFlow running with Settings → Agent Bridge switched on?`);
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function state() { return readJSON(P.state, {}); }
|
|
76
|
+
|
|
77
|
+
// A command file is written atomically (temp + rename) so the app never sees
|
|
78
|
+
// a half-written file. Then wait for the receipt the app writes.
|
|
79
|
+
// `keepEmpty` names fields where "" is meaningful (clear the notes, unassign).
|
|
80
|
+
// Pass as waitMs for a short wait in iCloud mode (setup's name registration).
|
|
81
|
+
// Any other value means the full wait there: the folder bridge answers in
|
|
82
|
+
// milliseconds, iCloud in seconds to minutes, so the folder timeouts the
|
|
83
|
+
// tools pass don't carry over.
|
|
84
|
+
const QUICK_WAIT = -1;
|
|
85
|
+
|
|
86
|
+
async function send(op, fields, waitMs = 8000, keepEmpty = []) {
|
|
87
|
+
const id = `mcp-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
88
|
+
const command = Object.assign({ id, op }, prune(fields, keepEmpty));
|
|
89
|
+
if (CLOUD) {
|
|
90
|
+
const clash = blockingClash();
|
|
91
|
+
if (clash) {
|
|
92
|
+
const fix = process.env.WF_AGENT_NAME !== undefined
|
|
93
|
+
? "Change it in Claude Desktop → Settings → Extensions → WorkFlow Companion → Name in WorkFlow."
|
|
94
|
+
: `Run "workflow-companion setup --name <another>", or add --takeover if this computer replaces ${clash.host}.`;
|
|
95
|
+
throw new Error(`Not sent: "${AGENT.name}" is also the name of Claude on ${clash.host}, so both computers would act on @${AGENT.name}. ${fix}`);
|
|
96
|
+
}
|
|
97
|
+
return cloud.send(Object.assign(command, { agentName: AGENT.name }), waitMs === QUICK_WAIT);
|
|
98
|
+
}
|
|
99
|
+
if (waitMs === QUICK_WAIT) waitMs = 5000;
|
|
100
|
+
if (!fs.existsSync(P.inbox)) throw new Error(`No inbox at ${P.inbox}. Is the Agent Bridge switched on?`);
|
|
101
|
+
const final = path.join(P.inbox, `${id}.json`);
|
|
102
|
+
const temp = path.join(P.inbox, `.${id}.json.tmp`);
|
|
103
|
+
fs.writeFileSync(temp, JSON.stringify(command, null, 2));
|
|
104
|
+
fs.renameSync(temp, final);
|
|
105
|
+
const receiptPath = path.join(P.outbox, `${id}.json`);
|
|
106
|
+
const deadline = Date.now() + waitMs;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
if (fs.existsSync(receiptPath)) {
|
|
109
|
+
const r = readJSON(receiptPath, null);
|
|
110
|
+
if (r) return r;
|
|
111
|
+
}
|
|
112
|
+
await delay(120);
|
|
113
|
+
}
|
|
114
|
+
return { status: "pending", message: `No receipt within ${waitMs / 1000}s — the app may be closed or the bridge off. The command stays in inbox/ and is applied when the app next looks.` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
118
|
+
|
|
119
|
+
function prune(obj, keepEmpty = []) {
|
|
120
|
+
const out = {};
|
|
121
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
122
|
+
if (v !== undefined && v !== null && (v !== "" || keepEmpty.includes(k))) out[k] = v;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------ iCloud
|
|
128
|
+
|
|
129
|
+
// Companion settings. "worflow" (no k) is the container's real name.
|
|
130
|
+
const COMPANION = {
|
|
131
|
+
container: "iCloud.TeamDZX.worflow.app",
|
|
132
|
+
env: "production",
|
|
133
|
+
zone: "AgentBridge",
|
|
134
|
+
// The hosted Apple sign-in page. The API token below is registered to this
|
|
135
|
+
// origin in CloudKit Console, and Apple checks the Origin header on every
|
|
136
|
+
// call, so the companion sends it too (a browser safeguard, not a secret).
|
|
137
|
+
signInURL: "https://companion.opticell-limited.com",
|
|
138
|
+
origin: "https://companion.opticell-limited.com",
|
|
139
|
+
// Production API token (CloudKit Console → Tokens & Keys). Not a secret:
|
|
140
|
+
// it only lets a signed-in user reach their own data. Set at release.
|
|
141
|
+
apiToken: "3442e21322e2ef2fb54c45c5af82b66f198b01a0cf4e02fe34318781dc4375f7",
|
|
142
|
+
home: path.join(os.homedir(), ".workflow-companion"),
|
|
143
|
+
};
|
|
144
|
+
const CP = {
|
|
145
|
+
config: path.join(COMPANION.home, "config.json"),
|
|
146
|
+
token: path.join(COMPANION.home, "token.txt"),
|
|
147
|
+
lock: path.join(COMPANION.home, "token.lock"),
|
|
148
|
+
snapshot: path.join(COMPANION.home, "snapshot.json"),
|
|
149
|
+
pending: path.join(COMPANION.home, "pending.json"),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const argv = process.argv.slice(2);
|
|
153
|
+
const CLOUD = argv.includes("--cloud") || process.env.WF_MODE === "cloud" ||
|
|
154
|
+
(!argv.includes("--folder") && process.env.WF_MODE !== "folder" && !process.env.WF_BRIDGE &&
|
|
155
|
+
(process.platform !== "darwin" || (fs.existsSync(CP.config) && !fs.existsSync(ROOT))));
|
|
156
|
+
|
|
157
|
+
const DEFAULT_COMPANION_NAME = "Companion";
|
|
158
|
+
function agentFor(name) {
|
|
159
|
+
const clean = String(name || "").trim().replace(/^@/, "") || DEFAULT_COMPANION_NAME;
|
|
160
|
+
return { name: clean, authorID: "agent." + clean.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") };
|
|
161
|
+
}
|
|
162
|
+
// Names must survive as an @mention and a CloudKit-safe id.
|
|
163
|
+
function validAgentName(name) { return /^[A-Za-z][A-Za-z0-9_-]{0,23}$/.test(String(name || "").trim()) && !/^claude$/i.test(String(name).trim()); }
|
|
164
|
+
// Claude Desktop sets the name in the extension's settings (WF_AGENT_NAME);
|
|
165
|
+
// the command line sets it at setup. An unusable name falls back to the default.
|
|
166
|
+
if (CLOUD) {
|
|
167
|
+
const fromDesktop = (process.env.WF_AGENT_NAME || "").trim();
|
|
168
|
+
const chosen = fromDesktop && !fromDesktop.startsWith("${") ? fromDesktop : (readJSON(CP.config, {}) || {}).agentName;
|
|
169
|
+
AGENT = agentFor(validAgentName(chosen) ? chosen : DEFAULT_COMPANION_NAME);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let cloudSnap = null; // the Mac's snapshot, as last downloaded
|
|
173
|
+
let cloudStatus = null; // heartbeats: { mac, mobile } (an iPhone or iPad can answer too)
|
|
174
|
+
let cloudLoadedAt = 0;
|
|
175
|
+
|
|
176
|
+
class SignInNeeded extends Error {}
|
|
177
|
+
|
|
178
|
+
function saveAtomic(file, text) {
|
|
179
|
+
fs.mkdirSync(COMPANION.home, { recursive: true });
|
|
180
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
181
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
182
|
+
fs.renameSync(tmp, file);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Claude Desktop has no terminal step: the extension's "Sign-in token"
|
|
186
|
+
// setting (kept by Claude Desktop, marked sensitive) arrives as
|
|
187
|
+
// WF_SIGNIN_TOKEN. A token not seen before is taken in once; from then on the
|
|
188
|
+
// rotating copy on disk is the live one. Pasting a fresh token into the
|
|
189
|
+
// setting is how a lapsed sign-in is renewed.
|
|
190
|
+
function importDesktopToken() {
|
|
191
|
+
const given = (process.env.WF_SIGNIN_TOKEN || "").replace(/\s+/g, "");
|
|
192
|
+
if (!given || given.startsWith("${")) return;
|
|
193
|
+
const seen = require("crypto").createHash("sha256").update(given).digest("hex");
|
|
194
|
+
const prior = readJSON(CP.config, null);
|
|
195
|
+
if (prior && prior.importedTokenSHA === seen && fs.existsSync(CP.token)) return;
|
|
196
|
+
const apiToken = (prior && prior.apiToken) || COMPANION.apiToken;
|
|
197
|
+
saveAtomic(CP.config, JSON.stringify({ container: COMPANION.container, env: (prior && prior.env) || COMPANION.env, apiToken,
|
|
198
|
+
origin: (prior && prior.origin) || COMPANION.origin, agentName: prior && prior.agentName,
|
|
199
|
+
since: new Date().toISOString(), importedTokenSHA: seen }, null, 2));
|
|
200
|
+
saveAtomic(CP.token, given);
|
|
201
|
+
try { fs.unlinkSync(CP.snapshot); } catch (_) {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function companionConfig() {
|
|
205
|
+
importDesktopToken();
|
|
206
|
+
const cfg = readJSON(CP.config, null);
|
|
207
|
+
if (!cfg || !fs.existsSync(CP.token)) throw new SignInNeeded(signInHelp("This computer isn't signed in to iCloud for WorkFlow yet."));
|
|
208
|
+
return cfg;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function signInHelp(why) {
|
|
212
|
+
if (process.env.WF_SIGNIN_TOKEN !== undefined) {
|
|
213
|
+
return `${why}\nSign in at ${COMPANION.signInURL} with the Apple Account WorkFlow uses on your iPhone or Mac, then paste the token it shows into Claude Desktop → Settings → Extensions → WorkFlow Companion → Sign-in token.`;
|
|
214
|
+
}
|
|
215
|
+
return `${why}\nIn a terminal on this computer, run:\n npx -y @opticell/workflow-companion setup\nand sign in with the Apple Account WorkFlow uses on your iPhone or Mac.`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const cloud = (() => {
|
|
219
|
+
// One request at a time, across every companion process on this computer.
|
|
220
|
+
// Apple answers each call with a new web auth token that replaces the old
|
|
221
|
+
// one: two requests in flight would each get a successor and one would be
|
|
222
|
+
// lost. In-process calls queue on `chain`; other processes wait on the lock
|
|
223
|
+
// file, and the token is re-read from disk once the lock is held.
|
|
224
|
+
let chain = Promise.resolve();
|
|
225
|
+
const serial = fn => { const run = chain.then(fn, fn); chain = run.catch(() => {}); return run; };
|
|
226
|
+
|
|
227
|
+
function lockHolderAlive(text) {
|
|
228
|
+
const pid = parseInt(text, 10);
|
|
229
|
+
if (!pid) return false;
|
|
230
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function withLock(fn) {
|
|
234
|
+
fs.mkdirSync(COMPANION.home, { recursive: true });
|
|
235
|
+
const deadline = Date.now() + 30000;
|
|
236
|
+
for (;;) {
|
|
237
|
+
try {
|
|
238
|
+
const fd = fs.openSync(CP.lock, "wx");
|
|
239
|
+
fs.writeSync(fd, String(process.pid));
|
|
240
|
+
fs.closeSync(fd);
|
|
241
|
+
break;
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (e.code !== "EEXIST") throw e;
|
|
244
|
+
let holder = "";
|
|
245
|
+
try { holder = fs.readFileSync(CP.lock, "utf8"); } catch (_) { continue; }
|
|
246
|
+
let age = 0;
|
|
247
|
+
try { age = Date.now() - fs.statSync(CP.lock).mtimeMs; } catch (_) { continue; }
|
|
248
|
+
// A holder that died, or one stuck far longer than any request takes.
|
|
249
|
+
if (!lockHolderAlive(holder) || age > 120000) { try { fs.unlinkSync(CP.lock); } catch (_) {} continue; }
|
|
250
|
+
if (Date.now() > deadline) throw new Error(`Another WorkFlow companion (process ${holder}) has held the iCloud sign-in for ${Math.round(age / 1000)}s. Try again shortly.`);
|
|
251
|
+
await delay(150);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try { return await fn(); } finally { try { fs.unlinkSync(CP.lock); } catch (_) {} }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// One CloudKit Web Services call. The successor token is saved before
|
|
258
|
+
// anything else happens with the answer - errors included.
|
|
259
|
+
function call(endpoint, body) {
|
|
260
|
+
return serial(() => withLock(async () => {
|
|
261
|
+
const cfg = companionConfig();
|
|
262
|
+
const token = fs.readFileSync(CP.token, "utf8").trim();
|
|
263
|
+
const base = `https://api.apple-cloudkit.com/database/1/${encodeURIComponent(cfg.container || COMPANION.container)}/${cfg.env || COMPANION.env}`;
|
|
264
|
+
const q = `ckAPIToken=${encodeURIComponent(cfg.apiToken)}&ckWebAuthToken=${encodeURIComponent(token)}`;
|
|
265
|
+
let res;
|
|
266
|
+
try {
|
|
267
|
+
res = await fetch(`${base}/${endpoint}?${q}`, {
|
|
268
|
+
method: body ? "POST" : "GET",
|
|
269
|
+
headers: Object.assign({ Origin: cfg.origin || COMPANION.origin }, body ? { "Content-Type": "application/json" } : {}),
|
|
270
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
271
|
+
});
|
|
272
|
+
} catch (e) {
|
|
273
|
+
throw new Error(`Can't reach iCloud (${(e.cause && e.cause.code) || e.message}). Check this computer's internet connection.`);
|
|
274
|
+
}
|
|
275
|
+
const next = res.headers.get("x-apple-cloudkit-web-auth-token");
|
|
276
|
+
if (next && next !== token) saveAtomic(CP.token, next);
|
|
277
|
+
const json = await res.json().catch(() => ({}));
|
|
278
|
+
if (res.status === 401 || res.status === 421 || json.serverErrorCode === "AUTHENTICATION_REQUIRED") {
|
|
279
|
+
throw new SignInNeeded(signInHelp(`The iCloud sign-in has ended (HTTP ${res.status}).`));
|
|
280
|
+
}
|
|
281
|
+
if (!res.ok) throw Object.assign(new Error(`iCloud refused ${endpoint}: ${json.reason || res.statusText} (${json.serverErrorCode || res.status})`), { code: json.serverErrorCode });
|
|
282
|
+
return json;
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const zoneID = { zoneName: COMPANION.zone };
|
|
287
|
+
|
|
288
|
+
function recordError(r) {
|
|
289
|
+
if (!r || !r.serverErrorCode) return null;
|
|
290
|
+
if (r.serverErrorCode === "ZONE_NOT_FOUND") return new Error("The companion isn't switched on in WorkFlow yet: WorkFlow → Settings → Windows Companion → Answer the Companion (iPhone, iPad), or Settings → Agent Bridge → Cloud Bridge (Mac). It sets up the iCloud side the first time it runs.");
|
|
291
|
+
return Object.assign(new Error(`iCloud: ${r.reason || r.serverErrorCode} (${r.serverErrorCode})`), { code: r.serverErrorCode });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Records by name; a missing one comes back as null.
|
|
295
|
+
async function lookup(names) {
|
|
296
|
+
const out = await call("private/records/lookup", { zoneID, records: names.map(recordName => ({ recordName })) });
|
|
297
|
+
const byName = {};
|
|
298
|
+
for (const [i, r] of (out.records || []).entries()) {
|
|
299
|
+
if (r.serverErrorCode === "NOT_FOUND") { byName[names[i]] = null; continue; }
|
|
300
|
+
const err = recordError(r);
|
|
301
|
+
if (err) throw err;
|
|
302
|
+
byName[r.recordName] = r;
|
|
303
|
+
}
|
|
304
|
+
return byName;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const field = (r, k) => r && r.fields && r.fields[k] ? r.fields[k].value : undefined;
|
|
308
|
+
|
|
309
|
+
// The Mac's heartbeat and its latest snapshot, in one lookup. The snapshot
|
|
310
|
+
// is downloaded only when its hash changes; a read burst within a few
|
|
311
|
+
// seconds reuses what's here.
|
|
312
|
+
async function load(force = false) {
|
|
313
|
+
if (!force && cloudSnap && Date.now() - cloudLoadedAt < 5000) return;
|
|
314
|
+
const got = await lookup(["snapshot", "status", "status-mobile"]);
|
|
315
|
+
const beat = r => r ? { aliveAt: field(r, "aliveAt"), appVersion: field(r, "appVersion"),
|
|
316
|
+
device: field(r, "device"), pollSeconds: field(r, "pollSeconds") } : null;
|
|
317
|
+
cloudStatus = { mac: beat(got.status), mobile: beat(got["status-mobile"]) };
|
|
318
|
+
const rec = got.snapshot;
|
|
319
|
+
cloudLoadedAt = Date.now();
|
|
320
|
+
if (!rec) return;
|
|
321
|
+
const sha = field(rec, "sha");
|
|
322
|
+
const cached = cloudSnap || readJSON(CP.snapshot, null);
|
|
323
|
+
if (cached && cached._sha === sha) { cloudSnap = cached; return; }
|
|
324
|
+
const asset = field(rec, "snapshot");
|
|
325
|
+
if (!asset || !asset.downloadURL) throw new Error("The snapshot in iCloud has no file attached.");
|
|
326
|
+
const res = await fetch(asset.downloadURL.replace("${f}", "snapshot.json"));
|
|
327
|
+
if (!res.ok) throw new Error(`Couldn't download the snapshot (HTTP ${res.status}).`);
|
|
328
|
+
const snap = await res.json();
|
|
329
|
+
snap._sha = sha;
|
|
330
|
+
cloudSnap = snap;
|
|
331
|
+
saveAtomic(CP.snapshot, JSON.stringify(snap));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Who answers: the Mac while it's awake (it stands the iPhone down), else
|
|
335
|
+
// an iPhone or iPad. One line per device that has ever checked in.
|
|
336
|
+
const MAC_FRESH_MIN = 10;
|
|
337
|
+
const minsAgo = b => Math.round((Date.now() - b.aliveAt) / 60000);
|
|
338
|
+
const agoText = m => m < 1 ? "just now" : m < 90 ? `${m} min ago` : m < 48 * 60 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} days ago`;
|
|
339
|
+
|
|
340
|
+
function macLine() {
|
|
341
|
+
const mac = cloudStatus && cloudStatus.mac && cloudStatus.mac.aliveAt ? cloudStatus.mac : null;
|
|
342
|
+
const mobile = cloudStatus && cloudStatus.mobile && cloudStatus.mobile.aliveAt ? cloudStatus.mobile : null;
|
|
343
|
+
if (!mac && !mobile) return "no Mac, iPhone or iPad has answered yet — switch on Cloud Bridge (Mac) or Answer the Companion (iPhone, iPad) in WorkFlow's Settings";
|
|
344
|
+
const parts = [];
|
|
345
|
+
if (mac) {
|
|
346
|
+
const m = minsAgo(mac);
|
|
347
|
+
parts.push(`WorkFlow ${mac.appVersion || "?"} on ${mac.device || "the Mac"}, last seen ${agoText(m)}${m > MAC_FRESH_MIN ? " (asleep, or WorkFlow is closed)" : " — answering"}`);
|
|
348
|
+
}
|
|
349
|
+
if (mobile) {
|
|
350
|
+
const m = minsAgo(mobile);
|
|
351
|
+
const standingBy = mac && minsAgo(mac) <= MAC_FRESH_MIN;
|
|
352
|
+
parts.push(`WorkFlow ${mobile.appVersion || "?"} on ${mobile.device || "an iPhone"}, last seen ${agoText(m)}${standingBy ? " (standing by while the Mac answers)" : " — answers when iOS next wakes WorkFlow, usually within minutes, or when it's opened"}`);
|
|
353
|
+
}
|
|
354
|
+
return parts.join("; ");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Commands that went out without a receipt in time. Each process keeps
|
|
358
|
+
// its own list (pending-<pid>.json) and collects only its own receipts, so
|
|
359
|
+
// two Claude sessions never take each other's. A list left by a process
|
|
360
|
+
// that has since exited is adopted by whichever one looks next; receipts
|
|
361
|
+
// found that way, or too late, go to late.json for workflow_state.
|
|
362
|
+
const pendingFile = pid => path.join(COMPANION.home, `pending-${pid}.json`);
|
|
363
|
+
const own = () => readJSON(pendingFile(process.pid), []);
|
|
364
|
+
const setOwn = rows => rows.length ? saveAtomic(pendingFile(process.pid), JSON.stringify(rows.slice(-50)))
|
|
365
|
+
: (() => { try { fs.unlinkSync(pendingFile(process.pid)); } catch (_) {} })();
|
|
366
|
+
function orphans() {
|
|
367
|
+
let files = [];
|
|
368
|
+
try { files = fs.readdirSync(COMPANION.home).filter(f => /^pending-\d+\.json$/.test(f)); } catch (_) {}
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const f of files) {
|
|
371
|
+
const pid = f.slice(8, -5);
|
|
372
|
+
if (pid === String(process.pid) || lockHolderAlive(pid)) continue;
|
|
373
|
+
for (const row of readJSON(path.join(COMPANION.home, f), [])) out.push(Object.assign({ file: f }, row));
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
const pending = () => own().concat(orphans());
|
|
378
|
+
|
|
379
|
+
// Under the lock: other processes append to the same files.
|
|
380
|
+
const locked = fn => serial(() => withLock(async () => fn()));
|
|
381
|
+
|
|
382
|
+
async function collect(current) {
|
|
383
|
+
const mine = own(), adopted = orphans();
|
|
384
|
+
const rows = [{ name: current }].concat(mine.filter(p => p.name !== current), adopted).slice(0, 20);
|
|
385
|
+
const got = await lookup(rows.map(p => `receipt-${p.name}`));
|
|
386
|
+
const found = {};
|
|
387
|
+
for (const p of rows) { const r = got[`receipt-${p.name}`]; if (r) found[p.name] = readJSONText(field(r, "payload")); }
|
|
388
|
+
const done = Object.keys(found);
|
|
389
|
+
if (!done.length) return null;
|
|
390
|
+
await call("private/records/modify", { zoneID, operations: done.map(n => ({ operationType: "forceDelete", record: { recordName: `receipt-${n}` } })) });
|
|
391
|
+
setOwn(own().filter(p => !found[p.name]));
|
|
392
|
+
const lateRows = mine.concat(adopted).filter(p => p.name !== current && found[p.name]);
|
|
393
|
+
await locked(() => {
|
|
394
|
+
for (const f of new Set(adopted.map(p => p.file))) {
|
|
395
|
+
const left = readJSON(path.join(COMPANION.home, f), []).filter(p => !found[p.name]);
|
|
396
|
+
if (left.length) saveAtomic(path.join(COMPANION.home, f), JSON.stringify(left));
|
|
397
|
+
else try { fs.unlinkSync(path.join(COMPANION.home, f)); } catch (_) {}
|
|
398
|
+
}
|
|
399
|
+
if (lateRows.length) saveAtomic(path.join(COMPANION.home, "late.json"), JSON.stringify(
|
|
400
|
+
readJSON(path.join(COMPANION.home, "late.json"), []).concat(lateRows.map(p => ({ op: p.op, at: p.at, receipt: found[p.name] }))).slice(-50)));
|
|
401
|
+
});
|
|
402
|
+
return found[current] || null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Late receipts, handed over once.
|
|
406
|
+
function takeLate() {
|
|
407
|
+
return locked(() => {
|
|
408
|
+
const file = path.join(COMPANION.home, "late.json");
|
|
409
|
+
const rows = readJSON(file, []);
|
|
410
|
+
try { fs.unlinkSync(file); } catch (_) {}
|
|
411
|
+
return rows;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function send(command, quick = false) {
|
|
416
|
+
const name = command.id;
|
|
417
|
+
const out = await call("private/records/modify", { zoneID, operations: [{ operationType: "create", record: {
|
|
418
|
+
recordType: "BridgeCommand", recordName: name,
|
|
419
|
+
fields: {
|
|
420
|
+
payload: { value: JSON.stringify(command), type: "STRING" },
|
|
421
|
+
source: { value: `companion@${os.hostname()}`, type: "STRING" },
|
|
422
|
+
createdAt: { value: Date.now(), type: "TIMESTAMP" },
|
|
423
|
+
},
|
|
424
|
+
} }] });
|
|
425
|
+
const err = recordError((out.records || [])[0]);
|
|
426
|
+
if (err) throw err;
|
|
427
|
+
setOwn(own().concat({ name, op: command.op, at: new Date().toISOString() }));
|
|
428
|
+
// A push usually brings the Mac within seconds; failing that it looks
|
|
429
|
+
// every 15 s. Check often at first, then wait out a little over two rounds.
|
|
430
|
+
const schedule = quick ? [2000, 3000, 5000] : [2000, 2000, 2000, 2000, 2000, 5000, 5000, 5000, 5000, 5000, 5000];
|
|
431
|
+
for (const wait of schedule) {
|
|
432
|
+
await delay(wait);
|
|
433
|
+
const receipt = await collect(name);
|
|
434
|
+
if (receipt) { cloudLoadedAt = 0; return receipt; }
|
|
435
|
+
}
|
|
436
|
+
await load(true).catch(() => {});
|
|
437
|
+
return { status: "pending", message: `Sent to iCloud, but WorkFlow hasn't answered within ${Math.round(schedule.reduce((a, b) => a + b) / 1000)} s (${macLine()}). The command waits safely in iCloud and runs when WorkFlow next looks; its receipt shows in workflow_state.` };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { call, lookup, load, send, macLine, pending, takeLate, collect, field };
|
|
441
|
+
})();
|
|
442
|
+
|
|
443
|
+
// The companions WorkFlow knows, from the hidden task the applying device
|
|
444
|
+
// keeps (5.2). Another computer already answering to a name makes the name
|
|
445
|
+
// unusable here: both would act on the same @mention.
|
|
446
|
+
function companionRoster() {
|
|
447
|
+
const holder = (cloudSnap && cloudSnap.tasks || []).find(t => t.title === "⚙︎ Agent companions");
|
|
448
|
+
try { return JSON.parse(holder && holder.pinnedNote || "[]"); } catch (_) { return []; }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function nameClash(name) {
|
|
452
|
+
const me = os.hostname();
|
|
453
|
+
return companionRoster().find(e => String(e.name).toLowerCase() === String(name).toLowerCase() && e.host && e.host !== me) || null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Checked before every command, so a name set in Claude Desktop's settings
|
|
457
|
+
// (which never runs setup) is held to the same rule. A clash stops counting
|
|
458
|
+
// once the other computer has been silent for two weeks (replaced or gone),
|
|
459
|
+
// or when setup --takeover recorded that this computer replaces it.
|
|
460
|
+
const CLASH_WINDOW_MS = 14 * 24 * 3600 * 1000;
|
|
461
|
+
function blockingClash() {
|
|
462
|
+
const clash = nameClash(AGENT.name);
|
|
463
|
+
if (!clash) return null;
|
|
464
|
+
if (clash.seen && Date.now() - new Date(clash.seen) > CLASH_WINDOW_MS) return null;
|
|
465
|
+
if ((readJSON(CP.config, {}) || {}).takeoverFrom === clash.host) return null;
|
|
466
|
+
return clash;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function readJSONText(text) { try { return JSON.parse(text); } catch (_) { return { status: "applied", message: String(text || "") }; } }
|
|
470
|
+
|
|
471
|
+
// ------------------------------------------------------------------ lookup
|
|
472
|
+
|
|
473
|
+
function findIn(rows, needle, label) {
|
|
474
|
+
const n = String(needle).trim().toLowerCase();
|
|
475
|
+
let hit = rows.find(r => String(r.id).toLowerCase() === n);
|
|
476
|
+
if (hit) return hit;
|
|
477
|
+
const exact = rows.filter(r => (r.title || "").toLowerCase() === n);
|
|
478
|
+
if (exact.length === 1) return exact[0];
|
|
479
|
+
const partial = rows.filter(r => (r.title || "").toLowerCase().includes(n));
|
|
480
|
+
if (partial.length === 1) return partial[0];
|
|
481
|
+
if (partial.length > 1) throw new Error(`"${needle}" matches ${partial.length} ${label}s: ${partial.slice(0, 5).map(r => `“${r.title}”`).join(", ")} — be more specific.`);
|
|
482
|
+
throw new Error(`No ${label} matches "${needle}".`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function findTask(needle, includeHidden = true) {
|
|
486
|
+
const rows = snapshot().tasks.filter(t => includeHidden || !t.isHidden);
|
|
487
|
+
return findIn(rows, needle, "task");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function findDocument(needle) {
|
|
491
|
+
const docs = snapshot().documents;
|
|
492
|
+
if (!docs) throw new Error("No documents in the snapshot — this needs WorkFlow 5.0 with the bridge on.");
|
|
493
|
+
const n = String(needle).trim().toLowerCase();
|
|
494
|
+
const hit = docs.find(d => d.id.toLowerCase() === n || (d.versions || []).some(v => v.id.toLowerCase() === n))
|
|
495
|
+
|| docs.find(d => (d.number || "").toLowerCase() === n)
|
|
496
|
+
|| docs.find(d => (d.name || "").toLowerCase() === n);
|
|
497
|
+
if (hit) return hit;
|
|
498
|
+
const partial = docs.filter(d => (d.name || "").toLowerCase().includes(n));
|
|
499
|
+
if (partial.length === 1) return partial[0];
|
|
500
|
+
if (partial.length > 1) throw new Error(`"${needle}" matches ${partial.length} documents: ${partial.slice(0, 5).map(d => `${d.number} “${d.name}”`).join(", ")} — use a WF-number.`);
|
|
501
|
+
throw new Error(`No document matches "${needle}".`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function documentLine(d) {
|
|
505
|
+
const where = d.task || (d.inWorkspace ? `workspace ${d.project}` : "note");
|
|
506
|
+
return `${d.number} v${d.version} ${d.name}${d.type ? ` [${d.type}]` : ""}${d.status ? ` (${d.status})` : ""} — ${where}${d.folder ? ` / ${d.folder}` : ""}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function stageFile(file) {
|
|
510
|
+
if (CLOUD) throw new Error("Files can't be sent through iCloud yet — attach it in WorkFlow itself, or put its text in a comment or note.");
|
|
511
|
+
const src = path.resolve(file.replace(/^~/, os.homedir()));
|
|
512
|
+
if (!fs.existsSync(src)) throw new Error(`no such file: ${src}`);
|
|
513
|
+
fs.mkdirSync(P.attachments, { recursive: true });
|
|
514
|
+
const staged = path.join(P.attachments, path.basename(src));
|
|
515
|
+
if (path.resolve(staged) !== src) fs.copyFileSync(src, staged);
|
|
516
|
+
return path.basename(src);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function findNote(needle) {
|
|
520
|
+
return findIn(snapshot().notes || [], needle, "note");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function local(iso) {
|
|
524
|
+
if (!iso) return "";
|
|
525
|
+
const d = new Date(iso);
|
|
526
|
+
return isNaN(d) ? iso : d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ------------------------------------------------------------------ views
|
|
530
|
+
|
|
531
|
+
function taskLine(t) {
|
|
532
|
+
const due = t.dueDate ? ` due ${local(t.dueDate).split(",")[0]}` : "";
|
|
533
|
+
const proj = t.project ? ` [${t.project}]` : "";
|
|
534
|
+
const who = t.assignee ? ` → ${t.assignee}` : "";
|
|
535
|
+
return `${t.status.padEnd(12)} ${t.title}${proj}${due}${who}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function showTask(t) {
|
|
539
|
+
const lines = [`${t.title} (${t.status}, ${t.priority})`, `id ${t.id}`];
|
|
540
|
+
if (t.project) lines.push(`project: ${t.project}`);
|
|
541
|
+
if (t.assignee) lines.push(`assignee: ${t.assignee}`);
|
|
542
|
+
if (t.dueDate) lines.push(`due: ${local(t.dueDate)}`);
|
|
543
|
+
if (t.tags && t.tags.length) lines.push(`tags: ${t.tags.join(", ")}`);
|
|
544
|
+
if (t.pinnedNote) lines.push(`status: ${t.pinnedNote}`);
|
|
545
|
+
if (t.notes) lines.push("", t.notes);
|
|
546
|
+
if (t.subtasks && t.subtasks.length) {
|
|
547
|
+
lines.push("");
|
|
548
|
+
for (const s of t.subtasks) lines.push(` [${s.isCompleted ? "x" : " "}] ${s.title}`);
|
|
549
|
+
}
|
|
550
|
+
const comments = t.comments || [];
|
|
551
|
+
lines.push("", `${comments.length} comment(s):`);
|
|
552
|
+
for (const c of comments) lines.push("", ` ${c.author} · ${local(c.timestamp)}`, ` ${(c.text || "").replace(/\n/g, "\n ")}`);
|
|
553
|
+
return lines.join("\n");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// "@Name" addressed to this agent, as a whole word.
|
|
557
|
+
function mentionsMe(text) {
|
|
558
|
+
const name = AGENT.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
559
|
+
return new RegExp(`(^|[^A-Za-z0-9_])@${name}(?![A-Za-z0-9_])`, "i").test(text || "");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// A task counts as addressed to this agent when one of its last three
|
|
563
|
+
// comments, written by a person, says @Name. Never in a shared project:
|
|
564
|
+
// a collaborator can write into those threads, so a mention there is not
|
|
565
|
+
// the owner asking (the same rule as the Mac's wake trigger).
|
|
566
|
+
function addressedToMe(t, sharedProjects) {
|
|
567
|
+
if (t.project && sharedProjects.has(t.project)) return false;
|
|
568
|
+
return (t.comments || []).slice(-3).some(c => !String(c.authorID || "").startsWith("agent.") && mentionsMe(c.text));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function showNote(n) {
|
|
572
|
+
const lines = [n.title || "(untitled)", `id ${n.id}`];
|
|
573
|
+
if (n.isPinned) lines.push("pinned");
|
|
574
|
+
if (n.isArchived) lines.push("archived");
|
|
575
|
+
if (n.convertedTargetName) lines.push(`converted → ${n.convertedTargetName}`);
|
|
576
|
+
lines.push(`updated: ${local(n.updatedAt)}`, "", n.body || "");
|
|
577
|
+
return lines.join("\n");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function receiptText(r) {
|
|
581
|
+
const tag = r.status === "applied" ? "[ok]" : `[${r.status}]`;
|
|
582
|
+
const extra = [r.taskID && `task ${r.taskID}`, r.noteID && `note ${r.noteID}`, r.projectID && `project ${r.projectID}`].filter(Boolean);
|
|
583
|
+
return `${tag} ${r.message || ""}${extra.length ? "\n " + extra.join(" ") : ""}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ------------------------------------------------------------------ tools
|
|
587
|
+
|
|
588
|
+
const TOOLS = [
|
|
589
|
+
{
|
|
590
|
+
name: "workflow_state",
|
|
591
|
+
description: "Whether the Agent Bridge is running, where it lives, and the app version. Use this first if anything else fails.",
|
|
592
|
+
inputSchema: { type: "object", properties: {} },
|
|
593
|
+
handler: async () => {
|
|
594
|
+
if (CLOUD) return cloudStateText();
|
|
595
|
+
const s = state();
|
|
596
|
+
const snap = readJSON(P.snapshot, null);
|
|
597
|
+
return [
|
|
598
|
+
`bridge ${ROOT}`,
|
|
599
|
+
`enabled ${s.enabled === undefined ? "unknown (no state.json)" : s.enabled}`,
|
|
600
|
+
`app ${s.appVersion || "?"} (${s.build || "?"})`,
|
|
601
|
+
`wake ${s.wakeEnabled ? "on" : "off"}`,
|
|
602
|
+
`snapshot ${snap ? `${snap.tasks.length} tasks, ${(snap.notes || []).length} notes, generated ${local(snap.generatedAt)}` : "none yet"}`,
|
|
603
|
+
].join("\n");
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
name: "workflow_list_tasks",
|
|
608
|
+
description: "List tasks with status, priority, project and due date.",
|
|
609
|
+
inputSchema: { type: "object", properties: { all: { type: "boolean", description: "Include hidden and completed tasks" } } },
|
|
610
|
+
handler: a => {
|
|
611
|
+
const rows = snapshot().tasks.filter(t => a.all || (!t.isHidden && t.status !== "Completed"));
|
|
612
|
+
return rows.length ? rows.map(taskLine).join("\n") : "no tasks";
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: "workflow_get_task",
|
|
617
|
+
description: "One task in full, with its checklist and comment thread. Accepts a task id or a fragment of its title.",
|
|
618
|
+
inputSchema: { type: "object", properties: { task: { type: "string" } }, required: ["task"] },
|
|
619
|
+
handler: a => showTask(findTask(a.task)),
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: "workflow_queue",
|
|
623
|
+
description: `Your inbox as "${AGENT.name}": tasks assigned to ${AGENT.name}, tasks whose recent comments say @${AGENT.name}, and notes that say @${AGENT.name} — each with why it's here, its status line, checklist and latest comment. Work meant for another agent (such as ${AGENT.name === "Claude" ? "a companion" : "Claude on the Mac"}) is not yours.`,
|
|
624
|
+
inputSchema: { type: "object", properties: {} },
|
|
625
|
+
handler: () => {
|
|
626
|
+
const snap = snapshot();
|
|
627
|
+
const shared = new Set((snap.projects || []).filter(p => p.isShared).map(p => p.name));
|
|
628
|
+
const mine = t => (t.assignee || "").toLowerCase() === AGENT.name.toLowerCase();
|
|
629
|
+
const rows = snap.tasks.filter(t => t.status !== "Completed" && !t.isHidden && (mine(t) || addressedToMe(t, shared)));
|
|
630
|
+
const notes = (snap.notes || []).filter(n => !n.isArchived && !n.convertedAt && mentionsMe(`${n.title || ""}\n${n.body || ""}`));
|
|
631
|
+
if (!rows.length && !notes.length) return "queue empty";
|
|
632
|
+
const out = rows.map(t => {
|
|
633
|
+
const last = (t.comments || []).slice(-1)[0];
|
|
634
|
+
const open = (t.subtasks || []).filter(s => !s.isCompleted).length;
|
|
635
|
+
const why = mine(t) ? `assigned to ${AGENT.name}` : `@${AGENT.name} in a comment`;
|
|
636
|
+
return [`${t.title} [${t.project || "—"}] id ${t.id} (${why})`, t.pinnedNote ? ` status: ${t.pinnedNote}` : null, open ? ` ${open} open subtask(s)` : null, last ? ` last: ${last.author}: ${last.text.slice(0, 160)}` : null].filter(Boolean).join("\n");
|
|
637
|
+
});
|
|
638
|
+
for (const n of notes) out.push(`note: ${n.title || "(untitled)"} id ${n.id} (@${AGENT.name} in the note — answer with workflow_reply_note)`);
|
|
639
|
+
return out.join("\n\n");
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "workflow_poll_comments",
|
|
644
|
+
description: "Comments the user has written recently, ignoring the agent's own. Defaults to the last 24 hours.",
|
|
645
|
+
inputSchema: { type: "object", properties: { since: { type: "string", description: "ISO-8601" } } },
|
|
646
|
+
handler: a => {
|
|
647
|
+
const since = a.since ? new Date(a.since) : new Date(Date.now() - 86400000);
|
|
648
|
+
const out = [];
|
|
649
|
+
for (const t of snapshot().tasks) for (const c of t.comments || []) {
|
|
650
|
+
if (c.authorID === AGENT.authorID) continue;
|
|
651
|
+
// Other agents' comments are theirs to follow up, not requests.
|
|
652
|
+
if (String(c.authorID || "").startsWith("agent.") && !mentionsMe(c.text)) continue;
|
|
653
|
+
if (new Date(c.timestamp) > since) out.push(`${local(c.timestamp)} [${t.title}] ${c.author}: ${c.text}`);
|
|
654
|
+
}
|
|
655
|
+
return out.length ? out.join("\n") : "nothing new";
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
name: "workflow_create_task",
|
|
660
|
+
description: `Create a task. Set assignee to "${AGENT.name}" to put it in your own queue.`,
|
|
661
|
+
inputSchema: {
|
|
662
|
+
type: "object",
|
|
663
|
+
properties: {
|
|
664
|
+
title: { type: "string" }, project: { type: "string" }, notes: { type: "string" },
|
|
665
|
+
due: { type: "string", description: "ISO-8601 or yyyy-MM-dd" },
|
|
666
|
+
priority: { type: "string", enum: ["High", "Medium", "Low"] },
|
|
667
|
+
status: { type: "string", description: "To Do / In Progress / Completed" },
|
|
668
|
+
assignee: { type: "string" }, status_note: { type: "string", description: "Seeds the pinned status line" },
|
|
669
|
+
tags: { type: "array", items: { type: "string" } }, subtasks: { type: "array", items: { type: "string" } },
|
|
670
|
+
comment: { type: "string", description: "An opening comment on the new task" },
|
|
671
|
+
},
|
|
672
|
+
required: ["title"],
|
|
673
|
+
},
|
|
674
|
+
handler: async a => receiptText(await send("create_task", { title: a.title, project: a.project, notes: a.notes, dueDate: a.due, priority: a.priority, status: a.status, assignee: a.assignee, pinnedNote: a.status_note, tags: a.tags, subtasks: a.subtasks, text: a.comment })),
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: "workflow_comment",
|
|
678
|
+
description: "Post a comment on a task, authored as the agent. This is how you reply to the user — it reaches their phone as a notification.",
|
|
679
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, text: { type: "string" }, mentions: { type: "array", items: { type: "string" } } }, required: ["task", "text"] },
|
|
680
|
+
handler: async a => receiptText(await send("add_comment", { taskID: findTask(a.task).id, text: a.text, mentions: a.mentions })),
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: "workflow_set_status_line",
|
|
684
|
+
description: "Set a task's pinned note — the one-line 'where this stands' shown above its comments. Overwrite it as work progresses rather than commenting each time.",
|
|
685
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, text: { type: "string" } }, required: ["task", "text"] },
|
|
686
|
+
handler: async a => receiptText(await send("update_task", { taskID: findTask(a.task).id, pinnedNote: a.text })),
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "workflow_check_subtasks",
|
|
690
|
+
description: "Tick subtasks off a task (or untick them), so progress on a long job is visible on the task's own row.",
|
|
691
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, subtasks: { type: "array", items: { type: "string" } }, uncheck: { type: "boolean" } }, required: ["task", "subtasks"] },
|
|
692
|
+
handler: async a => receiptText(await send("update_task", Object.assign({ taskID: findTask(a.task).id }, a.uncheck ? { uncheckSubtasks: a.subtasks } : { checkSubtasks: a.subtasks }))),
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
name: "workflow_add_subtasks",
|
|
696
|
+
description: "Add subtasks to an existing task's checklist.",
|
|
697
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, subtasks: { type: "array", items: { type: "string" } } }, required: ["task", "subtasks"] },
|
|
698
|
+
handler: async a => receiptText(await send("update_task", { taskID: findTask(a.task).id, addSubtasks: a.subtasks })),
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "workflow_update_task",
|
|
702
|
+
description: "Change an existing task. Only the fields you pass are touched. 'notes' REPLACES the notes; prefer 'append_notes', which adds to the end and keeps the user's formatting. 'add_tags'/'remove_tags' edit tags one at a time without dropping ones the user added.",
|
|
703
|
+
inputSchema: {
|
|
704
|
+
type: "object",
|
|
705
|
+
properties: {
|
|
706
|
+
task: { type: "string", description: "id or title" },
|
|
707
|
+
title: { type: "string" },
|
|
708
|
+
notes: { type: "string", description: "replaces the notes; empty string clears them" },
|
|
709
|
+
append_notes: { type: "string", description: "added to the end of the notes" },
|
|
710
|
+
due: { type: "string", description: "ISO-8601 or yyyy-MM-dd" },
|
|
711
|
+
no_due_date: { type: "boolean", description: "true removes the due date" },
|
|
712
|
+
priority: { type: "string", enum: ["High", "Medium", "Low"] },
|
|
713
|
+
status: { type: "string", description: "To Do / In Progress / Completed" },
|
|
714
|
+
assignee: { type: "string", description: "empty string unassigns" },
|
|
715
|
+
project: { type: "string", description: "empty string removes it from its project" },
|
|
716
|
+
add_tags: { type: "array", items: { type: "string" } },
|
|
717
|
+
remove_tags: { type: "array", items: { type: "string" } },
|
|
718
|
+
in_my_day: { type: "boolean" },
|
|
719
|
+
},
|
|
720
|
+
required: ["task"],
|
|
721
|
+
},
|
|
722
|
+
handler: async a => receiptText(await send("update_task", {
|
|
723
|
+
taskID: findTask(a.task).id, title: a.title, notes: a.notes, appendNotes: a.append_notes,
|
|
724
|
+
dueDate: a.due, hasDueDate: a.no_due_date ? false : undefined, priority: a.priority, status: a.status,
|
|
725
|
+
assignee: a.assignee, project: a.project, addTags: a.add_tags, removeTags: a.remove_tags, isInMyDay: a.in_my_day,
|
|
726
|
+
}, 8000, ["notes", "assignee", "project"])),
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
name: "workflow_remove_subtasks",
|
|
730
|
+
description: "Delete rows from a task's checklist. Each entry must be an exact title or a fragment only one row contains; anything ambiguous is left alone.",
|
|
731
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, subtasks: { type: "array", items: { type: "string" } } }, required: ["task", "subtasks"] },
|
|
732
|
+
handler: async a => receiptText(await send("update_task", { taskID: findTask(a.task).id, removeSubtasks: a.subtasks })),
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: "workflow_draft_email",
|
|
736
|
+
description: "Prepare an email a task asks for. It is attached to the task as a draft the user checks and sends with Review & Send; nothing is sent from here. Refused unless the user has set Settings → Agent Bridge → Email to Draft Only or Can Send.",
|
|
737
|
+
inputSchema: {
|
|
738
|
+
type: "object",
|
|
739
|
+
properties: {
|
|
740
|
+
task: { type: "string" },
|
|
741
|
+
to: { type: "array", items: { type: "string" }, description: "bare addresses, no display names" },
|
|
742
|
+
cc: { type: "array", items: { type: "string" } },
|
|
743
|
+
subject: { type: "string" }, body: { type: "string", description: "plain text" },
|
|
744
|
+
},
|
|
745
|
+
required: ["task", "to", "subject", "body"],
|
|
746
|
+
},
|
|
747
|
+
handler: async a => receiptText(await send("draft_email", { taskID: findTask(a.task).id, emailTo: a.to, emailCc: a.cc, emailSubject: a.subject, emailBody: a.body })),
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
name: "workflow_rename_subtask",
|
|
751
|
+
description: "Retitle one subtask in place, keeping its tick state — for correcting a row's wording (a version line on an index, say) without adding a duplicate.",
|
|
752
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, from: { type: "string", description: "current title, exact or a fragment it contains" }, to: { type: "string" } }, required: ["task", "from", "to"] },
|
|
753
|
+
handler: async a => receiptText(await send("update_task", { taskID: findTask(a.task).id, renameSubtasks: [{ from: a.from, to: a.to }] })),
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
name: "workflow_complete_task",
|
|
757
|
+
description: "Mark a task complete.",
|
|
758
|
+
inputSchema: { type: "object", properties: { task: { type: "string" } }, required: ["task"] },
|
|
759
|
+
handler: async a => receiptText(await send("complete_task", { taskID: findTask(a.task).id })),
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
name: "workflow_create_project",
|
|
763
|
+
description: "Create a project, or update an existing one of the same name.",
|
|
764
|
+
inputSchema: { type: "object", properties: { name: { type: "string" }, icon: { type: "string", description: "SF Symbol name" }, color: { type: "string", description: "#RRGGBB" }, description: { type: "string" },
|
|
765
|
+
status: { type: "string", enum: ["planning", "active", "onHold", "done"] },
|
|
766
|
+
start: { type: "string", description: "planned start yyyy-MM-dd, or 'none' to clear" },
|
|
767
|
+
target: { type: "string", description: "target date yyyy-MM-dd, or 'none' to clear" } }, required: ["name"] },
|
|
768
|
+
handler: async a => receiptText(await send("create_project", { name: a.name, icon: a.icon, colorHex: a.color, projectDescription: a.description,
|
|
769
|
+
projectStatus: a.status, startDate: a.start === "none" ? undefined : a.start, targetDate: a.target === "none" ? undefined : a.target,
|
|
770
|
+
clearStartDate: a.start === "none" ? true : undefined, clearTargetDate: a.target === "none" ? true : undefined })),
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
name: "workflow_list_notes",
|
|
774
|
+
description: `The Notepad. A '*' marks notes addressed to you (containing @${AGENT.name}).`,
|
|
775
|
+
inputSchema: { type: "object", properties: { all: { type: "boolean", description: "Include archived notes" } } },
|
|
776
|
+
handler: a => {
|
|
777
|
+
const rows = (snapshot().notes || []).filter(n => a.all || !n.isArchived);
|
|
778
|
+
if (!rows.length) return "no notes";
|
|
779
|
+
return rows.map(n => `${mentionsMe(`${n.title || ""}\n${n.body || ""}`) ? "*" : " "} ${(n.title || "(untitled)").padEnd(40)} ${local(n.updatedAt)} id ${n.id}`).join("\n");
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
name: "workflow_get_note",
|
|
784
|
+
description: "One note in full.",
|
|
785
|
+
inputSchema: { type: "object", properties: { note: { type: "string" } }, required: ["note"] },
|
|
786
|
+
handler: a => showNote(findNote(a.note)),
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: "workflow_create_note",
|
|
790
|
+
description: "File a note in the Notepad. Where output that isn't work belongs — a summary, a finding, a set of options. Use a task instead for anything that needs doing.",
|
|
791
|
+
inputSchema: { type: "object", properties: { title: { type: "string" }, body: { type: "string" }, color: { type: "string", description: "#RRGGBB" }, pin: { type: "boolean" } }, required: ["title"] },
|
|
792
|
+
handler: async a => receiptText(await send("create_note", { title: a.title, notes: a.body, colorHex: a.color, isPinned: a.pin })),
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: "workflow_reply_note",
|
|
796
|
+
description: "Answer a Notepad note inside the note itself: appends a signed, dated reply without changing what the user wrote. Notes have no comment thread, so this is how a note gets an answer on the user's phone.",
|
|
797
|
+
inputSchema: { type: "object", properties: { note: { type: "string" }, text: { type: "string" }, pin: { type: "boolean" } }, required: ["note", "text"] },
|
|
798
|
+
handler: async a => receiptText(await send("update_note", { noteID: findNote(a.note).id, appendText: a.text, isPinned: a.pin })),
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
name: "workflow_convert_note",
|
|
802
|
+
description: "Promote a note into a task, keeping the note and linking the two. Converting is what takes a note out of the inbox.",
|
|
803
|
+
inputSchema: { type: "object", properties: { note: { type: "string" }, project: { type: "string" }, title: { type: "string" }, priority: { type: "string", enum: ["High", "Medium", "Low"] }, due: { type: "string" }, assignee: { type: "string" }, subtasks: { type: "array", items: { type: "string" } } }, required: ["note"] },
|
|
804
|
+
handler: async a => receiptText(await send("convert_note", { noteID: findNote(a.note).id, project: a.project, title: a.title, priority: a.priority, dueDate: a.due, assignee: a.assignee, subtasks: a.subtasks })),
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
name: "workflow_attach_file",
|
|
808
|
+
description: "Attach a local file to a task (a report, a document, an image). The file is copied into the bridge folder and consumed by the app.",
|
|
809
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, file: { type: "string", description: "Path to the file on this Mac" }, name: { type: "string", description: "Display name; defaults to the filename" } }, required: ["task", "file"] },
|
|
810
|
+
handler: async a => {
|
|
811
|
+
const name = stageFile(a.file);
|
|
812
|
+
return receiptText(await send("attach_file", { taskID: findTask(a.task).id, fileName: name, displayName: a.name }, 15000));
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: "workflow_list_projects",
|
|
817
|
+
description: "The portfolio: every project's status, planned dates, progress and health (overdue / atRisk / idle / onTrack / planning / onHold / done) with the reason, as the Projects tab shows it.",
|
|
818
|
+
inputSchema: { type: "object", properties: { all: { type: "boolean", description: "include archived" } } },
|
|
819
|
+
handler: a => {
|
|
820
|
+
const rows = (snapshot().projects || []).filter(p => a.all || !p.isArchived);
|
|
821
|
+
if (rows.length && rows[0].health === undefined) throw new Error("This needs WorkFlow 5.1 with the bridge on.");
|
|
822
|
+
return rows.map(p => `${p.health.padEnd(9)} ${String(p.status).padEnd(9)} ${String(Math.round(p.progress * 100)).padStart(3)}% ${p.name}${p.targetDate ? ` target ${p.targetDate.slice(0, 10)}` : ""} — ${p.healthReason}`).join("\n") || "no projects";
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
name: "workflow_list_documents",
|
|
827
|
+
description: "List or search documents — every file on a task, on a note, or filed in a project's workspace. The query matches names, document numbers (WF-4F2A91), profiles and the text inside PDFs, scans and text files.",
|
|
828
|
+
inputSchema: { type: "object", properties: {
|
|
829
|
+
query: { type: "string" }, project: { type: "string" }, type: { type: "string" }, status: { type: "string" },
|
|
830
|
+
limit: { type: "number", description: "default 40" } } },
|
|
831
|
+
handler: a => {
|
|
832
|
+
const docs = snapshot().documents;
|
|
833
|
+
if (!docs) throw new Error("No documents in the snapshot — this needs WorkFlow 5.0 with the bridge on.");
|
|
834
|
+
const q = (a.query || "").toLowerCase();
|
|
835
|
+
const eq = (x, y) => !y || (x || "").toLowerCase() === y.toLowerCase();
|
|
836
|
+
const rows = docs.filter(d => eq(d.project, a.project) && eq(d.type, a.type) && eq(d.status, a.status) &&
|
|
837
|
+
(!q || ["number", "name", "type", "status", "author", "description", "folder", "project", "task", "text"]
|
|
838
|
+
.some(k => (d[k] || "").toLowerCase().includes(q))))
|
|
839
|
+
.sort((x, y) => (y.updatedAt || "").localeCompare(x.updatedAt || ""));
|
|
840
|
+
if (!rows.length) return "no documents match";
|
|
841
|
+
return rows.slice(0, a.limit || 40).map(documentLine).join("\n");
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
name: "workflow_get_document",
|
|
846
|
+
description: "One document in full: profile, folder, every version with its comment, and the start of its text.",
|
|
847
|
+
inputSchema: { type: "object", properties: { document: { type: "string", description: "WF-number, id or file name" } }, required: ["document"] },
|
|
848
|
+
handler: a => JSON.stringify(findDocument(a.document), null, 2),
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
name: "workflow_update_document",
|
|
852
|
+
description: "Change a document's profile or folder. Profile fields apply to every version; 'name' renames the current file. Folders nest with '/', and '' unfiles.",
|
|
853
|
+
inputSchema: { type: "object", properties: {
|
|
854
|
+
document: { type: "string" }, name: { type: "string" },
|
|
855
|
+
type: { type: "string", description: "Contract, Agreement, Invoice, Quote, Receipt, Letter, Email, Report, Proposal, Minutes, Specification, Drawing, Photo, Form, Policy, Other — or your own" },
|
|
856
|
+
status: { type: "string", description: "Draft, In Review, Final, Signed, Superseded" },
|
|
857
|
+
author: { type: "string" }, description: { type: "string" }, folder: { type: "string" } }, required: ["document"] },
|
|
858
|
+
handler: async a => receiptText(await send("update_document", { documentID: findDocument(a.document).id, documentName: a.name,
|
|
859
|
+
docType: a.type, docStatus: a.status, docAuthor: a.author, docSummary: a.description, docFolder: a.folder },
|
|
860
|
+
8000, ["docType", "docStatus", "docAuthor", "docSummary", "docFolder"])),
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
name: "workflow_file_document",
|
|
864
|
+
description: "File a local file straight into a project's workspace (not onto a task), with an optional profile and folder.",
|
|
865
|
+
inputSchema: { type: "object", properties: {
|
|
866
|
+
project: { type: "string" }, file: { type: "string", description: "Path to the file on this Mac" },
|
|
867
|
+
name: { type: "string" }, type: { type: "string" }, status: { type: "string" }, author: { type: "string" },
|
|
868
|
+
description: { type: "string" }, folder: { type: "string" } }, required: ["project", "file"] },
|
|
869
|
+
handler: async a => {
|
|
870
|
+
const name = stageFile(a.file);
|
|
871
|
+
return receiptText(await send("attach_file", { project: a.project, fileName: name, displayName: a.name, docType: a.type,
|
|
872
|
+
docStatus: a.status, docAuthor: a.author, docSummary: a.description, docFolder: a.folder }, 15000));
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
name: "workflow_add_document_version",
|
|
877
|
+
description: "Upload a new version of a document. The current file is kept in its history; the profile and folder carry over.",
|
|
878
|
+
inputSchema: { type: "object", properties: {
|
|
879
|
+
document: { type: "string" }, file: { type: "string", description: "Path to the file on this Mac" },
|
|
880
|
+
comment: { type: "string", description: "What changed" } }, required: ["document", "file"] },
|
|
881
|
+
handler: async a => {
|
|
882
|
+
const id = findDocument(a.document).id;
|
|
883
|
+
const name = stageFile(a.file);
|
|
884
|
+
return receiptText(await send("add_document_version", { documentID: id, fileName: name, versionComment: a.comment }, 15000));
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
name: "workflow_ping",
|
|
889
|
+
description: "Confirm the app is watching the bridge (round-trips one command).",
|
|
890
|
+
inputSchema: { type: "object", properties: {} },
|
|
891
|
+
handler: async () => receiptText(await send("ping", {}, 5000)),
|
|
892
|
+
},
|
|
893
|
+
];
|
|
894
|
+
|
|
895
|
+
const TOOLS_BY_NAME = Object.fromEntries(TOOLS.map(t => [t.name, t]));
|
|
896
|
+
const publicTools = () => TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
|
|
897
|
+
|
|
898
|
+
// ------------------------------------------------------------------ protocol
|
|
899
|
+
|
|
900
|
+
async function handle(request) {
|
|
901
|
+
const { method, id } = request;
|
|
902
|
+
const ok = result => ({ jsonrpc: "2.0", id, result });
|
|
903
|
+
const err = (code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
|
|
904
|
+
|
|
905
|
+
if (method === "initialize") return ok({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO });
|
|
906
|
+
if (method === "ping") return ok({});
|
|
907
|
+
if (method === "tools/list") return ok({ tools: publicTools() });
|
|
908
|
+
if (method === "tools/call") {
|
|
909
|
+
const params = request.params || {};
|
|
910
|
+
const tool = TOOLS_BY_NAME[params.name];
|
|
911
|
+
if (!tool) return err(-32602, `Unknown tool: ${params.name}`);
|
|
912
|
+
const args = params.arguments || {};
|
|
913
|
+
for (const req of (tool.inputSchema.required || [])) if (args[req] === undefined) return err(-32602, `Missing required argument: ${req}`);
|
|
914
|
+
try {
|
|
915
|
+
// In iCloud mode every tool reads the Mac's latest snapshot first
|
|
916
|
+
// (task lookups need it too); workflow_state reports on its own.
|
|
917
|
+
if (CLOUD && tool.name !== "workflow_state" && tool.name !== "workflow_ping") await cloud.load();
|
|
918
|
+
return ok({ content: [{ type: "text", text: String(await tool.handler(args)) }], isError: false });
|
|
919
|
+
} catch (e) {
|
|
920
|
+
return ok({ content: [{ type: "text", text: `${e.message || e}` }], isError: true });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (method && method.startsWith("notifications/")) return null;
|
|
924
|
+
return err(-32601, `Method not found: ${method}`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Requests are answered in the order they arrive.
|
|
928
|
+
function serve() {
|
|
929
|
+
let buffer = "";
|
|
930
|
+
let chain = Promise.resolve();
|
|
931
|
+
process.stdin.setEncoding("utf8");
|
|
932
|
+
process.stdin.on("data", chunk => {
|
|
933
|
+
buffer += chunk;
|
|
934
|
+
let nl;
|
|
935
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
936
|
+
const line = buffer.slice(0, nl).trim();
|
|
937
|
+
buffer = buffer.slice(nl + 1);
|
|
938
|
+
if (!line) continue;
|
|
939
|
+
let request;
|
|
940
|
+
try { request = JSON.parse(line); } catch (_) { continue; }
|
|
941
|
+
chain = chain.then(() => handle(request)).then(response => {
|
|
942
|
+
if (response) process.stdout.write(JSON.stringify(response) + "\n");
|
|
943
|
+
}, e => process.stderr.write(`workflow: ${e.stack || e}\n`));
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
// No process.exit(): on Windows it can abort Node while sockets are still
|
|
947
|
+
// closing (0xC0000409). Finish what's queued and let the loop drain.
|
|
948
|
+
process.stdin.on("end", () => { chain.then(() => { process.exitCode = 0; }); });
|
|
949
|
+
// Apple ends a sign-in left unused for somewhere between 9 and 14 hours
|
|
950
|
+
// (measured 28-29 Sep). Every call renews it, so while Claude has this
|
|
951
|
+
// running it touches iCloud every two hours; the scheduled keep-alive that
|
|
952
|
+
// setup installs covers the hours when it isn't running.
|
|
953
|
+
if (CLOUD) setInterval(() => { keepAlive().catch(() => {}); }, KEEPALIVE_EVERY_MS).unref();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function cloudStateText() {
|
|
957
|
+
const cfg = readJSON(CP.config, null);
|
|
958
|
+
const lines = [`mode iCloud (WorkFlow companion ${SERVER_INFO.version}), ${cfg ? cfg.env : COMPANION.env}`];
|
|
959
|
+
if (!cfg) return lines.concat("", signInHelp("Not signed in yet.")).join("\n");
|
|
960
|
+
let tokenAge = "";
|
|
961
|
+
try { tokenAge = local(fs.statSync(CP.token).mtime.toISOString()); } catch (_) {}
|
|
962
|
+
lines.push(`signed in yes — token last renewed ${tokenAge}`);
|
|
963
|
+
let lastKeepAlive = "";
|
|
964
|
+
try { lastKeepAlive = fs.readFileSync(KEEPALIVE_LOG, "utf8").trim().split("\n").pop(); } catch (_) {}
|
|
965
|
+
if (lastKeepAlive) lines.push(`keep-alive last run ${lastKeepAlive}`);
|
|
966
|
+
lines.push(`name ${AGENT.name} — assign tasks to "${AGENT.name}", or write @${AGENT.name} in a comment or note`);
|
|
967
|
+
try {
|
|
968
|
+
await cloud.load(true);
|
|
969
|
+
lines.push(`answering ${cloud.macLine()}`);
|
|
970
|
+
lines.push(`snapshot ${cloudSnap ? `${cloudSnap.tasks.length} tasks, ${(cloudSnap.notes || []).length} notes, generated ${local(cloudSnap.generatedAt)}` : "none yet — is the companion switched on in WorkFlow's Settings (Windows Companion on iPhone and iPad, Cloud Bridge on a Mac)?"}`);
|
|
971
|
+
const clash = cloudSnap ? nameClash(AGENT.name) : null;
|
|
972
|
+
if (clash) lines.push(`WARNING ${clash.host} also answers to "${AGENT.name}": both would act on @${AGENT.name}. Run setup with another --name.`);
|
|
973
|
+
} catch (e) {
|
|
974
|
+
lines.push(`iCloud ${e.message}`);
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
// Look once for receipts still owed, then report what came in late.
|
|
978
|
+
if (cloud.pending().length) await cloud.collect("");
|
|
979
|
+
for (const l of await cloud.takeLate()) lines.push(`late ${l.op} (sent ${local(l.at)}): ${receiptText(l.receipt)}`);
|
|
980
|
+
} catch (_) { /* reported above if iCloud is unreachable */ }
|
|
981
|
+
const waiting = cloud.pending();
|
|
982
|
+
if (waiting.length) lines.push(`waiting ${waiting.length} command(s) sent without a receipt yet: ${waiting.map(p => p.op).join(", ")}`);
|
|
983
|
+
return lines.join("\n");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ------------------------------------------------------------------ command line
|
|
987
|
+
|
|
988
|
+
function arg(name) { const i = argv.indexOf(`--${name}`); return i >= 0 ? argv[i + 1] : undefined; }
|
|
989
|
+
|
|
990
|
+
function ask(question) {
|
|
991
|
+
const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout });
|
|
992
|
+
return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim()); }));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Signs this computer in. The hosted page does the Apple sign-in and shows a
|
|
996
|
+
// token; it's pasted here (or passed with --web-token) and kept in
|
|
997
|
+
// ~/.workflow-companion, readable by this user only. The token is a password
|
|
998
|
+
// to the user's iCloud data for WorkFlow: it never goes in a chat.
|
|
999
|
+
async function setup() {
|
|
1000
|
+
const prior = readJSON(CP.config, {}) || {};
|
|
1001
|
+
const apiToken = arg("api-token") || prior.apiToken || COMPANION.apiToken;
|
|
1002
|
+
if (!apiToken) { console.error("No API token is built into this version. Pass --api-token."); return 2; }
|
|
1003
|
+
let webToken = arg("web-token");
|
|
1004
|
+
if (!webToken) {
|
|
1005
|
+
console.log(`WorkFlow companion — sign in\n\n1. Open ${COMPANION.signInURL} (opening it in your browser now).\n2. Sign in with the Apple Account WorkFlow uses on your iPhone or Mac.\n3. Copy the token the page shows and paste it here.\n`);
|
|
1006
|
+
if (!argv.includes("--no-browser")) openBrowser(COMPANION.signInURL);
|
|
1007
|
+
webToken = await ask("Token: ");
|
|
1008
|
+
}
|
|
1009
|
+
if (!webToken) { console.error("No token given; nothing changed."); return 2; }
|
|
1010
|
+
// The name this computer's Claude goes by in WorkFlow. Assign a task to it,
|
|
1011
|
+
// or write @name in a comment or note, to hand work to this machine.
|
|
1012
|
+
let agentName = arg("name") || prior.agentName;
|
|
1013
|
+
if (!agentName) {
|
|
1014
|
+
const answer = argv.includes("--web-token") ? "" : await ask(`\nWhat should Claude on this computer be called in WorkFlow? Tasks for it are assigned to this name,\nor mention @name. Not "Claude", which is the Mac's. [${DEFAULT_COMPANION_NAME}]: `);
|
|
1015
|
+
agentName = answer || DEFAULT_COMPANION_NAME;
|
|
1016
|
+
}
|
|
1017
|
+
agentName = agentName.trim().replace(/^@/, "");
|
|
1018
|
+
if (!validAgentName(agentName)) { console.error(`"${agentName}" won't work as a name: one word: a letter first, then letters, digits, - or _, up to 24 characters, and not "Claude".`); return 2; }
|
|
1019
|
+
saveAtomic(CP.config, JSON.stringify({ container: COMPANION.container, env: arg("env") || prior.env || COMPANION.env, apiToken,
|
|
1020
|
+
// The name is written only once it has passed the clash check below.
|
|
1021
|
+
origin: arg("origin") || prior.origin || COMPANION.origin, agentName: prior.agentName, since: new Date().toISOString() }, null, 2));
|
|
1022
|
+
saveAtomic(CP.token, webToken.replace(/\s+/g, ""));
|
|
1023
|
+
try { fs.unlinkSync(CP.snapshot); } catch (_) {}
|
|
1024
|
+
try {
|
|
1025
|
+
await cloud.call("public/users/caller");
|
|
1026
|
+
console.log("\nSigned in. Saved to " + COMPANION.home);
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
console.error(`\nThat token didn't work: ${e.message.split("\n")[0]}\nSign in again and copy a fresh token.`);
|
|
1029
|
+
return 1;
|
|
1030
|
+
}
|
|
1031
|
+
// One name, one computer: two machines answering to the same name would
|
|
1032
|
+
// both act on the same @mention.
|
|
1033
|
+
try { await cloud.load(true); } catch (_) { /* no snapshot yet: nothing to clash with */ }
|
|
1034
|
+
for (let clash = nameClash(agentName); clash && !argv.includes("--takeover"); clash = nameClash(agentName)) {
|
|
1035
|
+
const when = clash.seen ? local(clash.seen) : "some time ago";
|
|
1036
|
+
console.error(`\n"${agentName}" is already Claude on ${clash.host} (last heard from ${when}). Two computers with one name would both act on @${agentName}.`);
|
|
1037
|
+
if (argv.includes("--web-token") || argv.includes("--name")) {
|
|
1038
|
+
console.error(`Run setup again with another --name, or with --takeover if this computer replaces ${clash.host}.`);
|
|
1039
|
+
return 2;
|
|
1040
|
+
}
|
|
1041
|
+
const next = (await ask("Another name for this computer: ")).replace(/^@/, "").trim();
|
|
1042
|
+
if (!validAgentName(next)) { console.error(`"${next}" won't work as a name: one word, a letter first, up to 24 characters, not "Claude".`); return 2; }
|
|
1043
|
+
agentName = next;
|
|
1044
|
+
}
|
|
1045
|
+
const saved = readJSON(CP.config, {});
|
|
1046
|
+
saved.agentName = agentName;
|
|
1047
|
+
const replaced = nameClash(agentName);
|
|
1048
|
+
if (replaced) saved.takeoverFrom = replaced.host; else delete saved.takeoverFrom;
|
|
1049
|
+
saveAtomic(CP.config, JSON.stringify(saved, null, 2));
|
|
1050
|
+
AGENT = agentFor(agentName);
|
|
1051
|
+
// Claim the name now rather than at the first real command, so a second
|
|
1052
|
+
// computer set up in the meantime sees it's taken. A ping is harmless;
|
|
1053
|
+
// if the Mac or iPhone is asleep it registers the name when it wakes.
|
|
1054
|
+
console.log(`\nRegistering "${agentName}" with WorkFlow…`);
|
|
1055
|
+
try {
|
|
1056
|
+
const r = await send("ping", {}, QUICK_WAIT);
|
|
1057
|
+
console.log(r.status === "pending" ? " Queued: it registers when your Mac or iPhone next opens WorkFlow." : ` Done: WorkFlow knows this computer as "${agentName}".`);
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
console.log(` Not yet: ${e.message.split("\n")[0]}`);
|
|
1060
|
+
}
|
|
1061
|
+
console.log("\n" + (await cloudStateText()).split("\n").slice(1).join("\n"));
|
|
1062
|
+
if (argv.includes("--no-schedule") || process.env.WF_NO_SCHEDULE) {
|
|
1063
|
+
console.log(`\nNo keep-alive scheduled. An unused sign-in ends after 9 to 14 hours; run "workflow-companion keepalive" more often than that.`);
|
|
1064
|
+
} else {
|
|
1065
|
+
console.log(`\nKeep-alive: ${installSchedule()}. It keeps the sign-in from lapsing while Claude isn't running.`);
|
|
1066
|
+
}
|
|
1067
|
+
console.log(`\nNow add it to Claude Code:\n claude mcp add --scope user workflow -- npx -y @opticell/workflow-companion`);
|
|
1068
|
+
return 0;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function openBrowser(url) {
|
|
1072
|
+
const { spawn } = require("child_process");
|
|
1073
|
+
const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
1074
|
+
: process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
1075
|
+
try { spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch (_) { /* the URL is printed anyway */ }
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ------------------------------------------------------------------ keep-alive
|
|
1079
|
+
|
|
1080
|
+
const KEEPALIVE_EVERY_MS = 2 * 3600 * 1000;
|
|
1081
|
+
const SCHEDULE_HOURS = 3;
|
|
1082
|
+
const KEEPALIVE_LOG = path.join(COMPANION.home, "keepalive.log");
|
|
1083
|
+
const TASK_NAME = "WorkFlow Companion keep-alive";
|
|
1084
|
+
|
|
1085
|
+
// One cheap call, which renews the sign-in. Logged (last 50 lines) so a
|
|
1086
|
+
// lapse can be dated.
|
|
1087
|
+
async function keepAlive() {
|
|
1088
|
+
const at = new Date().toISOString();
|
|
1089
|
+
try {
|
|
1090
|
+
await cloud.call("public/users/caller");
|
|
1091
|
+
logKeepAlive(`${at} ok`);
|
|
1092
|
+
return 0;
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
logKeepAlive(`${at} ${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "failed"}: ${String(e.message).split("\n")[0]}`);
|
|
1095
|
+
return 1;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function logKeepAlive(line) {
|
|
1100
|
+
let lines = [];
|
|
1101
|
+
try { lines = fs.readFileSync(KEEPALIVE_LOG, "utf8").trim().split("\n"); } catch (_) {}
|
|
1102
|
+
try { fs.mkdirSync(COMPANION.home, { recursive: true }); fs.writeFileSync(KEEPALIVE_LOG, lines.concat(line).slice(-50).join("\n") + "\n"); } catch (_) {}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Installs the keep-alive as a scheduled job every few hours, running a copy
|
|
1106
|
+
// of this file kept in ~/.workflow-companion (npx's cache can be cleared, so
|
|
1107
|
+
// its path can't be scheduled). Windows: Task Scheduler, through wscript so no
|
|
1108
|
+
// console window flashes up. Elsewhere: the user's crontab.
|
|
1109
|
+
function installSchedule() {
|
|
1110
|
+
const { spawnSync } = require("child_process");
|
|
1111
|
+
const script = path.join(COMPANION.home, "workflow-companion.js");
|
|
1112
|
+
fs.mkdirSync(COMPANION.home, { recursive: true });
|
|
1113
|
+
fs.copyFileSync(__filename, script);
|
|
1114
|
+
const node = process.execPath;
|
|
1115
|
+
if (process.platform === "win32") {
|
|
1116
|
+
const vbs = path.join(COMPANION.home, "keepalive.vbs");
|
|
1117
|
+
const q = x => `""${x}""`;
|
|
1118
|
+
fs.writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${q(node)} ${q(script)} keepalive", 0, False\r\n`);
|
|
1119
|
+
const r = spawnSync("schtasks", ["/Create", "/F", "/SC", "HOURLY", "/MO", String(SCHEDULE_HOURS), "/TN", TASK_NAME,
|
|
1120
|
+
"/TR", `wscript.exe "${vbs}"`], { encoding: "utf8" });
|
|
1121
|
+
return r.status === 0 ? `Task Scheduler: "${TASK_NAME}", every ${SCHEDULE_HOURS} hours` : `couldn't add the scheduled task: ${(r.stderr || r.stdout || "").trim()}`;
|
|
1122
|
+
}
|
|
1123
|
+
const line = `17 */${SCHEDULE_HOURS} * * * "${node}" "${script}" keepalive >/dev/null 2>&1 # workflow-companion keepalive`;
|
|
1124
|
+
const current = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
|
1125
|
+
const kept = (current.status === 0 ? current.stdout : "").split("\n").filter(l => l.trim() && !l.includes("workflow-companion keepalive"));
|
|
1126
|
+
const r = spawnSync("crontab", ["-"], { input: kept.concat(line).join("\n") + "\n", encoding: "utf8" });
|
|
1127
|
+
return r.status === 0 ? `cron, every ${SCHEDULE_HOURS} hours` : `couldn't add the cron job: ${(r.stderr || "").trim()}`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function removeSchedule() {
|
|
1131
|
+
const { spawnSync } = require("child_process");
|
|
1132
|
+
if (process.platform === "win32") {
|
|
1133
|
+
spawnSync("schtasks", ["/Delete", "/F", "/TN", TASK_NAME], { encoding: "utf8" });
|
|
1134
|
+
} else {
|
|
1135
|
+
const current = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
|
1136
|
+
if (current.status === 0 && current.stdout.includes("workflow-companion keepalive")) {
|
|
1137
|
+
const kept = current.stdout.split("\n").filter(l => l.trim() && !l.includes("workflow-companion keepalive"));
|
|
1138
|
+
spawnSync("crontab", ["-"], { input: kept.join("\n") + (kept.length ? "\n" : ""), encoding: "utf8" });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
for (const f of ["workflow-companion.js", "keepalive.vbs", "keepalive.log"]) try { fs.unlinkSync(path.join(COMPANION.home, f)); } catch (_) {}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function printConfig() {
|
|
1145
|
+
if (CLOUD) {
|
|
1146
|
+
process.stdout.write(`Claude Code:\n claude mcp add --scope user workflow -- npx -y @opticell/workflow-companion\n\nClaude Desktop → Settings → Developer → Edit Config, add:\n${JSON.stringify({ mcpServers: { workflow: { command: "npx", args: ["-y", "@opticell/workflow-companion"] } } }, null, 2)}\n`);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
const cfg = { mcpServers: { workflow: { command: "node", args: [__filename], env: { WF_BRIDGE: ROOT } } } };
|
|
1150
|
+
process.stdout.write("Claude Desktop → Settings → Developer → Edit Config, add:\n" + JSON.stringify(cfg, null, 2) + "\n\nClaude Code:\n claude mcp add workflow -e WF_BRIDGE=\"" + ROOT + "\" -- node \"" + __filename + "\"\n");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const verb = argv.find(a => !a.startsWith("--"));
|
|
1154
|
+
const HELP = `WorkFlow companion ${SERVER_INFO.version}
|
|
1155
|
+
|
|
1156
|
+
workflow-companion run the MCP server (what Claude starts)
|
|
1157
|
+
workflow-companion setup sign this computer in to iCloud for WorkFlow (--name, --takeover, --no-browser)
|
|
1158
|
+
workflow-companion status which device answers, how fresh is the snapshot
|
|
1159
|
+
workflow-companion keepalive renew the sign-in (what the scheduled job runs)
|
|
1160
|
+
workflow-companion signout forget the sign-in on this computer
|
|
1161
|
+
workflow-companion --print-config
|
|
1162
|
+
|
|
1163
|
+
Needs WorkFlow 5.2+ on an iPhone, iPad or Mac, with the companion switched on in its Settings.
|
|
1164
|
+
`;
|
|
1165
|
+
|
|
1166
|
+
if (argv.includes("--print-config")) printConfig();
|
|
1167
|
+
else if (argv.includes("--help") || verb === "help") process.stdout.write(HELP);
|
|
1168
|
+
else if (verb === "setup") setup().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
1169
|
+
else if (verb === "status") cloudStateText().then(t => console.log(t), e => { console.error(e.message || e); process.exitCode = 1; });
|
|
1170
|
+
else if (verb === "keepalive") keepAlive().then(c => { process.exitCode = c; });
|
|
1171
|
+
else if (verb === "signout") {
|
|
1172
|
+
removeSchedule();
|
|
1173
|
+
for (const f of Object.values(CP)) try { fs.unlinkSync(f); } catch (_) {}
|
|
1174
|
+
console.log(`Signed out: removed the token, the settings and the keep-alive from ${COMPANION.home}.`);
|
|
1175
|
+
} else serve();
|