@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.
Files changed (4) hide show
  1. package/LICENSE +18 -7
  2. package/README.md +5 -2
  3. package/index.js +123 -21
  4. package/package.json +10 -2
package/LICENSE CHANGED
@@ -1,10 +1,21 @@
1
- WorkFlow Companion
2
- Copyright (c) 2026 Opticell Limited. All rights reserved.
1
+ MIT License
3
2
 
4
- You may install and run this software free of charge to use WorkFlow for Mac,
5
- which you must own, from other computers. You may not sell it, or distribute
6
- modified copies of it.
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. IN NO EVENT SHALL OPTICELL LIMITED BE LIABLE FOR ANY CLAIM, DAMAGES OR
10
- OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
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 don't travel yet.** Attaching a file, or adding a new version of a
137
- document, has to be done in WorkFlow itself.
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.5.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
- return { call, lookup, load, send, macLine, pending, takeLate, collect, field };
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
- function stageFile(file) {
511
- 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.");
512
- const src = path.resolve(file.replace(/^~/, os.homedir()));
513
- if (!fs.existsSync(src)) throw new Error(`no such file: ${src}`);
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, path.basename(src));
564
+ const staged = path.join(P.attachments, fileName);
516
565
  if (path.resolve(staged) !== src) fs.copyFileSync(src, staged);
517
- return path.basename(src);
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.comments || []).slice(-3).some(c => !String(c.authorID || "").startsWith("agent.") && mentionsMe(c.text));
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 Mac" }, name: { type: "string", description: "Display name; defaults to the filename" } }, required: ["task", "file"] },
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 name = stageFile(a.file);
820
- return receiptText(await send("attach_file", { taskID: findTask(a.task).id, fileName: name, displayName: a.name }, 15000));
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 Mac" },
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 name = stageFile(a.file);
879
- return receiptText(await send("attach_file", { project: a.project, fileName: name, displayName: a.name, docType: a.type,
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 Mac" },
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 name = stageFile(a.file);
892
- return receiptText(await send("add_document_version", { documentID: id, fileName: name, versionComment: a.comment }, 15000));
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] : byPeople.slice(-3).reverse().find(c => mentionsMe(c.text));
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
- console.log(`Waking: ${installWakeSchedule()}.\n Claude Code: ${claudeBin}\n Allowed without asking: ${cfg.wakeAllowedTools || "mcp__workflow"} (change with --allow-tools)\n Log: ${WAKE.log}`);
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.5.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": "SEE LICENSE IN 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
  }