@opticell/workflow-companion 1.5.1 → 1.6.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 +18 -7
- package/README.md +5 -2
- package/index.js +123 -21
- package/package.json +10 -2
package/LICENSE
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
Copyright (c) 2026 Opticell Limited. All rights reserved.
|
|
1
|
+
MIT License
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Copyright (c) 2026 Opticell Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
7
14
|
|
|
8
15
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
9
|
-
IMPLIED
|
|
10
|
-
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -133,8 +133,11 @@ npx -y @opticell/workflow-companion wake-on
|
|
|
133
133
|
shows later in `workflow_state`.
|
|
134
134
|
- **Reads come from a snapshot.** WorkFlow publishes it a few seconds after each
|
|
135
135
|
change, so a read straight after a write can lag slightly.
|
|
136
|
-
- **Files
|
|
137
|
-
|
|
136
|
+
- **Files go through your iCloud (1.6).** Attaching a file, filing a document
|
|
137
|
+
into a project or adding a new version uploads the file to your own iCloud;
|
|
138
|
+
WorkFlow 5.3 or later on the answering Mac or iPhone attaches it and then
|
|
139
|
+
deletes the uploaded copy. Up to 50 MB a file. An older WorkFlow ignores the
|
|
140
|
+
upload and reports the file as missing.
|
|
138
141
|
- **The token is a password.** `~/.workflow-companion/token.txt` opens your
|
|
139
142
|
WorkFlow data in iCloud. Apple replaces it on every request, and the
|
|
140
143
|
companion saves each replacement at once. Several Claude sessions on one
|
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.6.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
|
|
@@ -438,7 +438,45 @@ const cloud = (() => {
|
|
|
438
438
|
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.` };
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
|
|
441
|
+
// Files (1.6). The file goes up as its own record of the snapshot's type,
|
|
442
|
+
// whose "snapshot" asset field is already in the Production schema, so no
|
|
443
|
+
// schema change was needed. The device that applies the command fetches it
|
|
444
|
+
// by name and deletes it afterwards. CloudKit Web Services takes an asset
|
|
445
|
+
// in three steps: ask for an upload address, send the bytes there, then
|
|
446
|
+
// save a record that refers to what came back.
|
|
447
|
+
async function upload(file) {
|
|
448
|
+
const recordName = `upload-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
|
449
|
+
const asked = await call("private/assets/upload", { zoneID, tokens: [{ recordType: "BridgeSnapshot", fieldName: "snapshot", recordName }] });
|
|
450
|
+
const url = asked.tokens && asked.tokens[0] && asked.tokens[0].url;
|
|
451
|
+
if (!url) throw new Error("iCloud didn't give an address to upload the file to.");
|
|
452
|
+
let res;
|
|
453
|
+
try {
|
|
454
|
+
res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: fs.readFileSync(file) });
|
|
455
|
+
} catch (e) {
|
|
456
|
+
throw new Error(`Couldn't upload ${path.basename(file)} to iCloud (${(e.cause && e.cause.code) || e.message}).`);
|
|
457
|
+
}
|
|
458
|
+
if (!res.ok) throw new Error(`iCloud refused the upload of ${path.basename(file)} (HTTP ${res.status}).`);
|
|
459
|
+
const body = await res.json().catch(() => ({}));
|
|
460
|
+
if (!body.singleFile) throw new Error("iCloud took the file but didn't say where it is.");
|
|
461
|
+
const out = await call("private/records/modify", { zoneID, operations: [{ operationType: "create", record: {
|
|
462
|
+
recordType: "BridgeSnapshot", recordName,
|
|
463
|
+
fields: {
|
|
464
|
+
snapshot: { value: body.singleFile },
|
|
465
|
+
sha: { value: path.basename(file), type: "STRING" },
|
|
466
|
+
generatedAt: { value: Date.now(), type: "TIMESTAMP" },
|
|
467
|
+
},
|
|
468
|
+
} }] });
|
|
469
|
+
const err = recordError((out.records || [])[0]);
|
|
470
|
+
if (err) throw err;
|
|
471
|
+
return recordName;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function discardUpload(recordName) {
|
|
475
|
+
if (!/^upload-/.test(recordName)) return;
|
|
476
|
+
await call("private/records/modify", { zoneID, operations: [{ operationType: "forceDelete", record: { recordName } }] });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { call, lookup, load, send, macLine, pending, takeLate, collect, field, upload, discardUpload };
|
|
442
480
|
})();
|
|
443
481
|
|
|
444
482
|
// The companions WorkFlow knows, from the hidden task the applying device
|
|
@@ -507,14 +545,43 @@ function documentLine(d) {
|
|
|
507
545
|
return `${d.number} v${d.version} ${d.name}${d.type ? ` [${d.type}]` : ""}${d.status ? ` (${d.status})` : ""} — ${where}${d.folder ? ` / ${d.folder}` : ""}`;
|
|
508
546
|
}
|
|
509
547
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
548
|
+
// WorkFlow's own limit on one attachment (PMAttachment.maxAttachmentBytes).
|
|
549
|
+
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
550
|
+
|
|
551
|
+
// Gets a file to WorkFlow. On the Mac it is copied into the bridge folder;
|
|
552
|
+
// from a companion (1.6) it is uploaded to the user's iCloud first and the
|
|
553
|
+
// command names the upload. Returns the fields the command needs.
|
|
554
|
+
async function stageFile(file) {
|
|
555
|
+
const src = path.resolve(String(file).replace(/^~/, os.homedir()));
|
|
556
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) throw new Error(`no such file: ${src}`);
|
|
557
|
+
const fileName = path.basename(src);
|
|
558
|
+
if (CLOUD) {
|
|
559
|
+
const size = fs.statSync(src).size;
|
|
560
|
+
if (size > MAX_FILE_BYTES) throw new Error(`${fileName} is ${(size / 1048576).toFixed(1)} MB; WorkFlow takes files up to 50 MB.`);
|
|
561
|
+
return { fileName, upload: await cloud.upload(src) };
|
|
562
|
+
}
|
|
514
563
|
fs.mkdirSync(P.attachments, { recursive: true });
|
|
515
|
-
const staged = path.join(P.attachments,
|
|
564
|
+
const staged = path.join(P.attachments, fileName);
|
|
516
565
|
if (path.resolve(staged) !== src) fs.copyFileSync(src, staged);
|
|
517
|
-
return
|
|
566
|
+
return { fileName };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Sends a file command. If the command never reaches iCloud, the upload it
|
|
570
|
+
// named is removed again rather than left behind.
|
|
571
|
+
async function sendWithFile(op, fields, staged, waitMs) {
|
|
572
|
+
try {
|
|
573
|
+
const receipt = await send(op, Object.assign({}, fields, { fileName: staged.fileName, upload: staged.upload }), waitMs);
|
|
574
|
+
// WorkFlow 5.3+ deletes the upload itself. One that refused the command
|
|
575
|
+
// (or an older WorkFlow, which doesn't know uploads) leaves it, so it
|
|
576
|
+
// goes here. Still pending: leave it, the command will need it.
|
|
577
|
+
if (staged.upload && receipt && (receipt.status === "failed" || receipt.status === "rejected")) {
|
|
578
|
+
await cloud.discardUpload(staged.upload).catch(() => {});
|
|
579
|
+
}
|
|
580
|
+
return receipt;
|
|
581
|
+
} catch (e) {
|
|
582
|
+
if (staged.upload) await cloud.discardUpload(staged.upload).catch(() => {});
|
|
583
|
+
throw e;
|
|
584
|
+
}
|
|
518
585
|
}
|
|
519
586
|
|
|
520
587
|
function findNote(needle) {
|
|
@@ -577,7 +644,35 @@ function mentionsMe(text) {
|
|
|
577
644
|
// the owner asking (the same rule as the Mac's wake trigger).
|
|
578
645
|
function addressedToMe(t, sharedProjects) {
|
|
579
646
|
if (t.project && sharedProjects.has(t.project)) return false;
|
|
580
|
-
return (t
|
|
647
|
+
return !!addressingComment(t);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Assistant hand-offs allowed in a row before a person must write again
|
|
651
|
+
// (1.6, matching WorkFlow 5.3.1). Enough for "Claude, check this" -> "Jarvis,
|
|
652
|
+
// done" -> a reply; never enough for two assistants to wake each other forever.
|
|
653
|
+
const MAX_AGENT_HOPS = 3;
|
|
654
|
+
const isAgentID = id => String(id || "").startsWith("agent.");
|
|
655
|
+
|
|
656
|
+
// The comment asking this agent for something, if any. A person's @Name
|
|
657
|
+
// counts in any of the last three comments. Another assistant's counts only
|
|
658
|
+
// as the newest comment, and only while the run of assistant comments with an
|
|
659
|
+
// @mention since a person last wrote is within MAX_AGENT_HOPS. Before 1.6 an
|
|
660
|
+
// assistant's comment never counted, so Claude's "@Jarvis, brief above" woke
|
|
661
|
+
// nothing.
|
|
662
|
+
function addressingComment(t) {
|
|
663
|
+
const comments = t.comments || [];
|
|
664
|
+
// The newest request wins: a fresh assistant hand-off must count even when
|
|
665
|
+
// an older @mention from a person is still among the last three.
|
|
666
|
+
const last = comments[comments.length - 1];
|
|
667
|
+
if (last && isAgentID(last.authorID) && last.authorID !== AGENT.authorID && mentionsMe(last.text)) {
|
|
668
|
+
let hops = 0;
|
|
669
|
+
for (const c of [...comments].reverse()) {
|
|
670
|
+
if (!isAgentID(c.authorID)) break;
|
|
671
|
+
if (/(^|[^A-Za-z0-9_])@[A-Za-z]/.test(c.text || "")) hops++;
|
|
672
|
+
}
|
|
673
|
+
if (hops <= MAX_AGENT_HOPS) return last;
|
|
674
|
+
}
|
|
675
|
+
return comments.slice(-3).reverse().find(c => !isAgentID(c.authorID) && mentionsMe(c.text)) || null;
|
|
581
676
|
}
|
|
582
677
|
|
|
583
678
|
function showNote(n) {
|
|
@@ -814,10 +909,11 @@ const TOOLS = [
|
|
|
814
909
|
{
|
|
815
910
|
name: "workflow_attach_file",
|
|
816
911
|
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.",
|
|
817
|
-
inputSchema: { type: "object", properties: { task: { type: "string" }, file: { type: "string", description: "Path to the file on this
|
|
912
|
+
inputSchema: { type: "object", properties: { task: { type: "string" }, file: { type: "string", description: "Path to the file on this computer" }, name: { type: "string", description: "Display name; defaults to the filename" } }, required: ["task", "file"] },
|
|
818
913
|
handler: async a => {
|
|
819
|
-
const
|
|
820
|
-
|
|
914
|
+
const taskID = findTask(a.task).id;
|
|
915
|
+
const staged = await stageFile(a.file);
|
|
916
|
+
return receiptText(await sendWithFile("attach_file", { taskID, displayName: a.name }, staged, 15000));
|
|
821
917
|
},
|
|
822
918
|
},
|
|
823
919
|
{
|
|
@@ -871,25 +967,25 @@ const TOOLS = [
|
|
|
871
967
|
name: "workflow_file_document",
|
|
872
968
|
description: "File a local file straight into a project's workspace (not onto a task), with an optional profile and folder.",
|
|
873
969
|
inputSchema: { type: "object", properties: {
|
|
874
|
-
project: { type: "string" }, file: { type: "string", description: "Path to the file on this
|
|
970
|
+
project: { type: "string" }, file: { type: "string", description: "Path to the file on this computer" },
|
|
875
971
|
name: { type: "string" }, type: { type: "string" }, status: { type: "string" }, author: { type: "string" },
|
|
876
972
|
description: { type: "string" }, folder: { type: "string" } }, required: ["project", "file"] },
|
|
877
973
|
handler: async a => {
|
|
878
|
-
const
|
|
879
|
-
return receiptText(await
|
|
880
|
-
docStatus: a.status, docAuthor: a.author, docSummary: a.description, docFolder: a.folder }, 15000));
|
|
974
|
+
const staged = await stageFile(a.file);
|
|
975
|
+
return receiptText(await sendWithFile("attach_file", { project: a.project, displayName: a.name, docType: a.type,
|
|
976
|
+
docStatus: a.status, docAuthor: a.author, docSummary: a.description, docFolder: a.folder }, staged, 15000));
|
|
881
977
|
},
|
|
882
978
|
},
|
|
883
979
|
{
|
|
884
980
|
name: "workflow_add_document_version",
|
|
885
981
|
description: "Upload a new version of a document. The current file is kept in its history; the profile and folder carry over.",
|
|
886
982
|
inputSchema: { type: "object", properties: {
|
|
887
|
-
document: { type: "string" }, file: { type: "string", description: "Path to the file on this
|
|
983
|
+
document: { type: "string" }, file: { type: "string", description: "Path to the file on this computer" },
|
|
888
984
|
comment: { type: "string", description: "What changed" } }, required: ["document", "file"] },
|
|
889
985
|
handler: async a => {
|
|
890
986
|
const id = findDocument(a.document).id;
|
|
891
|
-
const
|
|
892
|
-
return receiptText(await
|
|
987
|
+
const staged = await stageFile(a.file);
|
|
988
|
+
return receiptText(await sendWithFile("add_document_version", { documentID: id, versionComment: a.comment }, staged, 15000));
|
|
893
989
|
},
|
|
894
990
|
},
|
|
895
991
|
{
|
|
@@ -1199,7 +1295,7 @@ function wakeItems(snap) {
|
|
|
1199
1295
|
const personal = c => !String(c.authorID || "").startsWith("agent.");
|
|
1200
1296
|
const items = rows.map(t => {
|
|
1201
1297
|
const byPeople = (t.comments || []).filter(personal);
|
|
1202
|
-
const trigger = mine(t) ? byPeople.slice(-1)[0] :
|
|
1298
|
+
const trigger = mine(t) ? byPeople.slice(-1)[0] : addressingComment(t);
|
|
1203
1299
|
const why = mine(t) ? `assigned to ${AGENT.name}` : `@${AGENT.name} in a comment`;
|
|
1204
1300
|
return { key: `task:${t.id}`, sig: trigger ? `${trigger.id || ""}@${trigger.timestamp || ""}` : "assigned",
|
|
1205
1301
|
kind: "task", id: t.id, title: t.title, project: t.project || "", why };
|
|
@@ -1246,6 +1342,8 @@ Do the work, then report back in the task's own thread so it reaches the user's
|
|
|
1246
1342
|
workflow_complete_task only when the task is plainly finished
|
|
1247
1343
|
For a [note], answer with workflow_reply_note, or workflow_convert_note if it describes work worth tracking.
|
|
1248
1344
|
|
|
1345
|
+
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.
|
|
1346
|
+
|
|
1249
1347
|
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.
|
|
1250
1348
|
|
|
1251
1349
|
You may only have some tools available. If a request needs a tool you don't have (for example running commands on this computer), say so plainly in the thread rather than working around it.
|
|
@@ -1433,7 +1531,11 @@ async function wakeSetup(on) {
|
|
|
1433
1531
|
}
|
|
1434
1532
|
}
|
|
1435
1533
|
saveAtomic(CP.config, JSON.stringify(cfg, null, 2));
|
|
1436
|
-
|
|
1534
|
+
// The same switch as the keep-alive: tests (and anyone running by hand)
|
|
1535
|
+
// must never land in the real crontab or Task Scheduler.
|
|
1536
|
+
const scheduled = argv.includes("--no-schedule") || process.env.WF_NO_SCHEDULE
|
|
1537
|
+
? `not scheduled (--no-schedule); run "workflow-companion wake" yourself` : installWakeSchedule();
|
|
1538
|
+
console.log(`Waking: ${scheduled}.\n Claude Code: ${claudeBin}\n Allowed without asking: ${cfg.wakeAllowedTools || "mcp__workflow"} (change with --allow-tools)\n Log: ${WAKE.log}`);
|
|
1437
1539
|
return 0;
|
|
1438
1540
|
}
|
|
1439
1541
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opticell/workflow-companion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"mcpName": "io.github.TeamDzX/workflow-companion",
|
|
4
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.",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"mcp",
|
|
@@ -12,7 +13,7 @@
|
|
|
12
13
|
],
|
|
13
14
|
"homepage": "https://www.opticell-limited.com/companion",
|
|
14
15
|
"author": "Opticell Limited",
|
|
15
|
-
"license": "
|
|
16
|
+
"license": "MIT",
|
|
16
17
|
"bin": {
|
|
17
18
|
"workflow-companion": "index.js"
|
|
18
19
|
},
|
|
@@ -26,5 +27,12 @@
|
|
|
26
27
|
},
|
|
27
28
|
"publishConfig": {
|
|
28
29
|
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/TeamDzX/workflow-companion.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/TeamDzX/workflow-companion/issues"
|
|
29
37
|
}
|
|
30
38
|
}
|