@opticell/workflow-companion 1.5.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -7
- package/README.md +41 -7
- package/index.js +329 -25
- 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
|
@@ -31,19 +31,32 @@ many devices are on.
|
|
|
31
31
|
1. In WorkFlow on an iPhone or iPad: **Settings → Windows Companion → Answer
|
|
32
32
|
the Companion**. On a Mac: **Settings → Agent Bridge**, then the Agent
|
|
33
33
|
Bridge and **Cloud Bridge** switches.
|
|
34
|
-
2.
|
|
34
|
+
2. Install **Node.js 18 or later** (LTS) if `node -v` doesn't show it:
|
|
35
|
+
Windows `winget install OpenJS.NodeJS.LTS -e`, Mac `brew install node`,
|
|
36
|
+
Linux your package manager, or the installer from <https://nodejs.org>.
|
|
37
|
+
Then close and reopen the terminal (and restart Claude Code) so `node` and
|
|
38
|
+
`npx` are found; otherwise you'll see "node is not recognized".
|
|
39
|
+
3. In a normal terminal window:
|
|
35
40
|
|
|
36
41
|
```sh
|
|
37
|
-
npx -y @opticell/workflow-companion setup
|
|
42
|
+
npx -y @opticell/workflow-companion@latest setup
|
|
38
43
|
```
|
|
39
44
|
|
|
40
45
|
It opens <https://companion.opticell-limited.com> to sign in with the
|
|
41
46
|
Apple Account WorkFlow uses on your iPhone or Mac, then asks you to paste the token that
|
|
42
|
-
page shows.
|
|
43
|
-
|
|
47
|
+
page shows. It asks questions, so it can't run from Claude Code's `!`
|
|
48
|
+
prompt or another non-interactive shell; there, pass `--web-token <token>`
|
|
49
|
+
and `--name <name>`.
|
|
50
|
+
4. Add it to Claude:
|
|
44
51
|
|
|
45
52
|
```sh
|
|
46
|
-
claude mcp add --scope user workflow -- npx -y @opticell/workflow-companion
|
|
53
|
+
claude mcp add --scope user workflow -- npx -y @opticell/workflow-companion@latest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
On **Windows**, `npx` is a script, so Claude Code needs `cmd /c` in front:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
claude mcp add --scope user workflow -- cmd /c npx -y @opticell/workflow-companion@latest
|
|
47
60
|
```
|
|
48
61
|
|
|
49
62
|
`--scope user` makes it available in every project on this computer.
|
|
@@ -113,6 +126,22 @@ npx -y @opticell/workflow-companion wake-on
|
|
|
113
126
|
found. `--skip-existing` treats what's already queued as handled.
|
|
114
127
|
- The log is `~/.workflow-companion/wake.log`. `wake-off` stops it all.
|
|
115
128
|
|
|
129
|
+
## Staying up to date
|
|
130
|
+
|
|
131
|
+
- **Claude Code** runs whatever its registration names. Register it with
|
|
132
|
+
`@latest` (setup prints the exact line) and every new session uses the
|
|
133
|
+
newest release.
|
|
134
|
+
- **The scheduled jobs** (keep-alive, waking) run a saved copy that npm never
|
|
135
|
+
touches, so they update themselves. At most every 6 hours they read npm's
|
|
136
|
+
entry for the package (`registry.npmjs.org/@opticell/workflow-companion/latest`).
|
|
137
|
+
When it's newer, they download the release, check it against npm's sha512
|
|
138
|
+
checksum and that Node can parse it, then swap it in. Turn this off with
|
|
139
|
+
`auto-update off`, and update by hand with `update`.
|
|
140
|
+
- **Its version is reported.** Every ten minutes or so while it runs, each
|
|
141
|
+
companion writes its version, computer and check-in time to your iCloud.
|
|
142
|
+
WorkFlow 5.3.2 shows them in Settings → Agent Bridge → Who's Bridged, and
|
|
143
|
+
`status` lists every companion's version.
|
|
144
|
+
|
|
116
145
|
## Commands
|
|
117
146
|
|
|
118
147
|
| | |
|
|
@@ -121,6 +150,8 @@ npx -y @opticell/workflow-companion wake-on
|
|
|
121
150
|
| `workflow-companion setup` | Signs this computer in. Takes `--web-token` to skip the prompt. |
|
|
122
151
|
| `workflow-companion status` | Which device answers, how fresh the snapshot is, any receipts that arrived late. |
|
|
123
152
|
| `workflow-companion keepalive` | Renews the sign-in. The scheduled job runs this. |
|
|
153
|
+
| `workflow-companion update` | Updates the scheduled jobs' copy to the latest release now, and shows how to keep Claude Code on `@latest`. |
|
|
154
|
+
| `workflow-companion auto-update on\|off` | Whether the scheduled jobs update themselves (on by default). |
|
|
124
155
|
| `workflow-companion wake-on` | Starts a Claude Code session on its own when work for its name arrives. See above. |
|
|
125
156
|
| `workflow-companion wake-off` | Stops that. Work waits until you ask Claude here. |
|
|
126
157
|
| `workflow-companion wake` | One check of the queue. The scheduled job runs this. |
|
|
@@ -133,8 +164,11 @@ npx -y @opticell/workflow-companion wake-on
|
|
|
133
164
|
shows later in `workflow_state`.
|
|
134
165
|
- **Reads come from a snapshot.** WorkFlow publishes it a few seconds after each
|
|
135
166
|
change, so a read straight after a write can lag slightly.
|
|
136
|
-
- **Files
|
|
137
|
-
|
|
167
|
+
- **Files go through your iCloud (1.6).** Attaching a file, filing a document
|
|
168
|
+
into a project or adding a new version uploads the file to your own iCloud;
|
|
169
|
+
WorkFlow 5.3 or later on the answering Mac or iPhone attaches it and then
|
|
170
|
+
deletes the uploaded copy. Up to 50 MB a file. An older WorkFlow ignores the
|
|
171
|
+
upload and reports the file as missing.
|
|
138
172
|
- **The token is a password.** `~/.workflow-companion/token.txt` opens your
|
|
139
173
|
WorkFlow data in iCloud. Apple replaces it on every request, and the
|
|
140
174
|
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.1" };
|
|
31
31
|
// Who the agent is in WorkFlow: the assignee its queue follows, the @name
|
|
32
32
|
// that calls it, the signature on what it writes. On the Mac it's Claude.
|
|
33
33
|
// A companion goes by the name chosen at setup (default "Companion"), so a
|
|
@@ -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
|
{
|
|
@@ -922,7 +1018,10 @@ async function handle(request) {
|
|
|
922
1018
|
try {
|
|
923
1019
|
// In iCloud mode every tool reads the Mac's latest snapshot first
|
|
924
1020
|
// (task lookups need it too); workflow_state reports on its own.
|
|
925
|
-
if (CLOUD && tool.name !== "workflow_state" && tool.name !== "workflow_ping")
|
|
1021
|
+
if (CLOUD && tool.name !== "workflow_state" && tool.name !== "workflow_ping") {
|
|
1022
|
+
await cloud.load();
|
|
1023
|
+
reportPresence().catch(() => {});
|
|
1024
|
+
}
|
|
926
1025
|
return ok({ content: [{ type: "text", text: String(await tool.handler(args)) }], isError: false });
|
|
927
1026
|
} catch (e) {
|
|
928
1027
|
return ok({ content: [{ type: "text", text: `${e.message || e}` }], isError: true });
|
|
@@ -972,11 +1071,18 @@ async function cloudStateText() {
|
|
|
972
1071
|
try { lastKeepAlive = fs.readFileSync(KEEPALIVE_LOG, "utf8").trim().split("\n").pop(); } catch (_) {}
|
|
973
1072
|
if (lastKeepAlive) lines.push(`keep-alive last run ${lastKeepAlive}`);
|
|
974
1073
|
lines.push(`waking ${cfg.wakeEnabled ? `on — Claude Code starts by itself when @${AGENT.name} work arrives (log ${WAKE.log})` : "off — work waits until Claude here is asked (turn on: workflow-companion wake-on)"}`);
|
|
1074
|
+
lines.push(`version ${await versionLine()}`);
|
|
975
1075
|
lines.push(`name ${AGENT.name} — assign tasks to "${AGENT.name}", or write @${AGENT.name} in a comment or note`);
|
|
976
1076
|
try {
|
|
977
1077
|
await cloud.load(true);
|
|
978
1078
|
lines.push(`answering ${cloud.macLine()}`);
|
|
979
1079
|
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)?"}`);
|
|
1080
|
+
try {
|
|
1081
|
+
const ago = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? "just now" : m < 90 ? `${m} min ago` : m < 2880 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} days ago`; };
|
|
1082
|
+
for (const c of await companionPresence()) {
|
|
1083
|
+
lines.push(`companion ${c.name}: ${c.version ? `${c.version} on ${c.host || "?"}, checked in ${ago(c.aliveAt)}${c.waking ? ", waking on" : ""}` : "hasn't reported a version (older than 1.6.1)"}`);
|
|
1084
|
+
}
|
|
1085
|
+
} catch (_) { /* presence is a nicety */ }
|
|
980
1086
|
const clash = cloudSnap ? nameClash(AGENT.name) : null;
|
|
981
1087
|
if (clash) lines.push(`WARNING ${clash.host} also answers to "${AGENT.name}": both would act on @${AGENT.name}. Run setup with another --name.`);
|
|
982
1088
|
} catch (e) {
|
|
@@ -1073,7 +1179,8 @@ async function setup() {
|
|
|
1073
1179
|
} else {
|
|
1074
1180
|
console.log(`\nKeep-alive: ${installSchedule()}. It keeps the sign-in from lapsing while Claude isn't running.`);
|
|
1075
1181
|
}
|
|
1076
|
-
|
|
1182
|
+
await reportPresence(true);
|
|
1183
|
+
console.log(`\nNow add it to Claude Code (@latest, so each new session picks up the newest release):\n ${claudeCodeCommand()}`);
|
|
1077
1184
|
if (argv.includes("--wake") && !argv.includes("--no-schedule") && !process.env.WF_NO_SCHEDULE) {
|
|
1078
1185
|
console.log("");
|
|
1079
1186
|
await wakeSetup(true);
|
|
@@ -1103,7 +1210,9 @@ async function keepAlive() {
|
|
|
1103
1210
|
const at = new Date().toISOString();
|
|
1104
1211
|
try {
|
|
1105
1212
|
await cloud.call("public/users/caller");
|
|
1106
|
-
|
|
1213
|
+
await reportPresence();
|
|
1214
|
+
const update = await selfUpdate();
|
|
1215
|
+
logKeepAlive(`${at} ok${/^updated|failed/.test(update) ? `; ${update}` : ""}`);
|
|
1107
1216
|
return 0;
|
|
1108
1217
|
} catch (e) {
|
|
1109
1218
|
logKeepAlive(`${at} ${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "failed"}: ${String(e.message).split("\n")[0]}`);
|
|
@@ -1199,7 +1308,7 @@ function wakeItems(snap) {
|
|
|
1199
1308
|
const personal = c => !String(c.authorID || "").startsWith("agent.");
|
|
1200
1309
|
const items = rows.map(t => {
|
|
1201
1310
|
const byPeople = (t.comments || []).filter(personal);
|
|
1202
|
-
const trigger = mine(t) ? byPeople.slice(-1)[0] :
|
|
1311
|
+
const trigger = mine(t) ? byPeople.slice(-1)[0] : addressingComment(t);
|
|
1203
1312
|
const why = mine(t) ? `assigned to ${AGENT.name}` : `@${AGENT.name} in a comment`;
|
|
1204
1313
|
return { key: `task:${t.id}`, sig: trigger ? `${trigger.id || ""}@${trigger.timestamp || ""}` : "assigned",
|
|
1205
1314
|
kind: "task", id: t.id, title: t.title, project: t.project || "", why };
|
|
@@ -1246,6 +1355,8 @@ Do the work, then report back in the task's own thread so it reaches the user's
|
|
|
1246
1355
|
workflow_complete_task only when the task is plainly finished
|
|
1247
1356
|
For a [note], answer with workflow_reply_note, or workflow_convert_note if it describes work worth tracking.
|
|
1248
1357
|
|
|
1358
|
+
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.
|
|
1359
|
+
|
|
1249
1360
|
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
1361
|
|
|
1251
1362
|
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.
|
|
@@ -1298,6 +1409,7 @@ async function wake() {
|
|
|
1298
1409
|
try {
|
|
1299
1410
|
try { await cloud.load(true); }
|
|
1300
1411
|
catch (e) { logWake(`${e instanceof SignInNeeded ? "SIGN-IN ENDED" : "iCloud failed"}: ${String(e.message).split("\n")[0]}`); return 1; }
|
|
1412
|
+
await reportPresence();
|
|
1301
1413
|
if (!cloudSnap) return 0;
|
|
1302
1414
|
if (blockingClash()) { logWake(`not waking: "${AGENT.name}" is also Claude on another computer`); return 0; }
|
|
1303
1415
|
const st = readJSON(WAKE.state, null) || {};
|
|
@@ -1352,6 +1464,7 @@ async function wake() {
|
|
|
1352
1464
|
return 1;
|
|
1353
1465
|
} finally {
|
|
1354
1466
|
try { fs.unlinkSync(WAKE.lock); } catch (_) {}
|
|
1467
|
+
try { const update = await selfUpdate(); if (/^updated|failed/.test(update)) logWake(update); } catch (_) {}
|
|
1355
1468
|
}
|
|
1356
1469
|
}
|
|
1357
1470
|
|
|
@@ -1433,13 +1546,200 @@ async function wakeSetup(on) {
|
|
|
1433
1546
|
}
|
|
1434
1547
|
}
|
|
1435
1548
|
saveAtomic(CP.config, JSON.stringify(cfg, null, 2));
|
|
1436
|
-
|
|
1549
|
+
// The same switch as the keep-alive: tests (and anyone running by hand)
|
|
1550
|
+
// must never land in the real crontab or Task Scheduler.
|
|
1551
|
+
const scheduled = argv.includes("--no-schedule") || process.env.WF_NO_SCHEDULE
|
|
1552
|
+
? `not scheduled (--no-schedule); run "workflow-companion wake" yourself` : installWakeSchedule();
|
|
1553
|
+
console.log(`Waking: ${scheduled}.\n Claude Code: ${claudeBin}\n Allowed without asking: ${cfg.wakeAllowedTools || "mcp__workflow"} (change with --allow-tools)\n Log: ${WAKE.log}`);
|
|
1554
|
+
return 0;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ------------------------------------------------------------------ versions (1.6.1)
|
|
1558
|
+
|
|
1559
|
+
// Each companion says which version it runs, and keeps itself current.
|
|
1560
|
+
//
|
|
1561
|
+
// Presence: a "companion-<name>" record of the Mac's own status type
|
|
1562
|
+
// (BridgeStatus: aliveAt, appVersion, device, pollSeconds, all already in the
|
|
1563
|
+
// Production schema), so WorkFlow's Who's Bridged and other companions can
|
|
1564
|
+
// show "Jarvis · 1.6.1 · checked in 4 min ago".
|
|
1565
|
+
//
|
|
1566
|
+
// Latest release: npm's registry entry for the package, which is JSON and is
|
|
1567
|
+
// by definition what was last published. Checked at most every 6 hours.
|
|
1568
|
+
//
|
|
1569
|
+
// Self-update: the scheduled jobs run a saved copy that npm never touches.
|
|
1570
|
+
// When a newer release is out, that copy is replaced by the published file,
|
|
1571
|
+
// after checking it against npm's own sha512 and that Node can parse it.
|
|
1572
|
+
// Claude Code stays current by being registered with @latest.
|
|
1573
|
+
const RELEASE_URL = "https://registry.npmjs.org/@opticell/workflow-companion/latest";
|
|
1574
|
+
const RELEASE_CHECK_MS = 6 * 3600 * 1000;
|
|
1575
|
+
const PRESENCE_EVERY_MS = 10 * 60 * 1000;
|
|
1576
|
+
const SAVED_SCRIPT = path.join(COMPANION.home, "workflow-companion.js");
|
|
1577
|
+
|
|
1578
|
+
// true when version a is newer than b ("1.10.0" > "1.9.3").
|
|
1579
|
+
function newerVersion(a, b) {
|
|
1580
|
+
const pa = String(a || "").split(".").map(n => parseInt(n, 10) || 0);
|
|
1581
|
+
const pb = String(b || "").split(".").map(n => parseInt(n, 10) || 0);
|
|
1582
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
|
|
1583
|
+
return false;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function updateConfig(change) {
|
|
1587
|
+
if (!fs.existsSync(CP.config)) return;
|
|
1588
|
+
const cfg = readJSON(CP.config, {}) || {};
|
|
1589
|
+
change(cfg);
|
|
1590
|
+
saveAtomic(CP.config, JSON.stringify(cfg, null, 2));
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// The newest published release, from npm (cached for 6 hours in config.json).
|
|
1594
|
+
async function latestRelease(force = false) {
|
|
1595
|
+
const cached = (readJSON(CP.config, {}) || {}).latestRelease || null;
|
|
1596
|
+
if (!force && cached && Date.now() - cached.checkedAt < RELEASE_CHECK_MS) return cached;
|
|
1597
|
+
try {
|
|
1598
|
+
const ctl = new AbortController();
|
|
1599
|
+
const timer = setTimeout(() => ctl.abort(), 8000);
|
|
1600
|
+
const res = await fetch(RELEASE_URL, { signal: ctl.signal });
|
|
1601
|
+
clearTimeout(timer);
|
|
1602
|
+
if (!res.ok) return cached;
|
|
1603
|
+
const j = await res.json();
|
|
1604
|
+
if (!j || !j.version) return cached;
|
|
1605
|
+
const info = { version: j.version, tarball: j.dist && j.dist.tarball, integrity: j.dist && j.dist.integrity, checkedAt: Date.now() };
|
|
1606
|
+
updateConfig(cfg => { cfg.latestRelease = info; });
|
|
1607
|
+
return info;
|
|
1608
|
+
} catch (_) {
|
|
1609
|
+
return cached;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// The version a saved copy of this file declares.
|
|
1614
|
+
function versionOfScript(file) {
|
|
1615
|
+
try {
|
|
1616
|
+
const m = fs.readFileSync(file, "utf8").match(/const SERVER_INFO = \{ name: "workflow", version: "([0-9.]+)" \};/);
|
|
1617
|
+
return m ? m[1] : null;
|
|
1618
|
+
} catch (_) { return null; }
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// package/index.js out of an npm tarball (gzip'd ustar). No dependencies.
|
|
1622
|
+
function indexFromTarball(gz) {
|
|
1623
|
+
const tar = require("zlib").gunzipSync(gz);
|
|
1624
|
+
const text = (from, len) => tar.toString("utf8", from, from + len).replace(/\0[\s\S]*$/, "");
|
|
1625
|
+
for (let off = 0; off + 512 <= tar.length;) {
|
|
1626
|
+
if (tar.subarray(off, off + 512).every(b => b === 0)) break;
|
|
1627
|
+
const name = text(off, 100), prefix = text(off + 345, 155);
|
|
1628
|
+
const size = parseInt(text(off + 124, 12).trim(), 8) || 0;
|
|
1629
|
+
const start = off + 512;
|
|
1630
|
+
if ((prefix ? `${prefix}/${name}` : name) === "package/index.js") return tar.subarray(start, start + size);
|
|
1631
|
+
off = start + Math.ceil(size / 512) * 512;
|
|
1632
|
+
}
|
|
1633
|
+
return null;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Replaces the saved copy the scheduled jobs run, if a newer release is out.
|
|
1637
|
+
// Returns "updated from X to Y", "current", "off", or a reason it didn't.
|
|
1638
|
+
async function selfUpdate({ force = false } = {}) {
|
|
1639
|
+
const cfg = readJSON(CP.config, null);
|
|
1640
|
+
if (!cfg) return "not signed in";
|
|
1641
|
+
if (cfg.autoUpdate === false && !force) return "off";
|
|
1642
|
+
if (!fs.existsSync(SAVED_SCRIPT)) return "nothing scheduled";
|
|
1643
|
+
const saved = versionOfScript(SAVED_SCRIPT);
|
|
1644
|
+
const rel = await latestRelease(force);
|
|
1645
|
+
if (!rel || !rel.version) return "couldn't reach npm";
|
|
1646
|
+
if (!newerVersion(rel.version, saved)) return "current";
|
|
1647
|
+
try {
|
|
1648
|
+
if (!rel.tarball || !/^sha512-/.test(rel.integrity || "")) throw new Error("npm gave no tarball or checksum");
|
|
1649
|
+
const res = await fetch(rel.tarball);
|
|
1650
|
+
if (!res.ok) throw new Error(`download failed (HTTP ${res.status})`);
|
|
1651
|
+
const gz = Buffer.from(await res.arrayBuffer());
|
|
1652
|
+
const sum = "sha512-" + require("crypto").createHash("sha512").update(gz).digest("base64");
|
|
1653
|
+
if (sum !== rel.integrity) throw new Error("checksum didn't match npm's");
|
|
1654
|
+
const code = indexFromTarball(gz);
|
|
1655
|
+
if (!code) throw new Error("no index.js in the package");
|
|
1656
|
+
// A .js name: Node won't --check a file with any other extension.
|
|
1657
|
+
const tmp = path.join(COMPANION.home, `workflow-companion.${process.pid}.new.js`);
|
|
1658
|
+
fs.writeFileSync(tmp, code);
|
|
1659
|
+
const check = require("child_process").spawnSync(process.execPath, ["--check", tmp], { encoding: "utf8" });
|
|
1660
|
+
if (check.status !== 0) { try { fs.unlinkSync(tmp); } catch (_) {} throw new Error("the new file didn't parse"); }
|
|
1661
|
+
fs.renameSync(tmp, SAVED_SCRIPT);
|
|
1662
|
+
return `updated from ${saved || "?"} to ${rel.version}`;
|
|
1663
|
+
} catch (e) {
|
|
1664
|
+
return `update to ${rel.version} failed: ${e.message}`;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// "I'm here, this version": at most every 10 minutes unless forced.
|
|
1669
|
+
async function reportPresence(force = false) {
|
|
1670
|
+
if (!CLOUD) return;
|
|
1671
|
+
const cfg = readJSON(CP.config, null);
|
|
1672
|
+
if (!cfg || !fs.existsSync(CP.token)) return;
|
|
1673
|
+
if (!force && cfg.presenceAt && Date.now() - cfg.presenceAt < PRESENCE_EVERY_MS) return;
|
|
1674
|
+
const slug = AGENT.authorID.slice("agent.".length);
|
|
1675
|
+
try {
|
|
1676
|
+
await cloud.call("private/records/modify", { zoneID: { zoneName: COMPANION.zone }, operations: [{ operationType: "forceReplace", record: {
|
|
1677
|
+
recordType: "BridgeStatus", recordName: `companion-${slug}`,
|
|
1678
|
+
fields: {
|
|
1679
|
+
aliveAt: { value: Date.now(), type: "TIMESTAMP" },
|
|
1680
|
+
appVersion: { value: SERVER_INFO.version, type: "STRING" },
|
|
1681
|
+
device: { value: os.hostname(), type: "STRING" },
|
|
1682
|
+
// Seconds between wake checks; 0 when waking is off.
|
|
1683
|
+
pollSeconds: { value: cfg.wakeEnabled ? WAKE.everyMinutes * 60 : 0, type: "INT64" },
|
|
1684
|
+
},
|
|
1685
|
+
} }] });
|
|
1686
|
+
updateConfig(c => { c.presenceAt = Date.now(); });
|
|
1687
|
+
} catch (_) { /* reported next time */ }
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Every companion's last check-in, from their presence records.
|
|
1691
|
+
async function companionPresence() {
|
|
1692
|
+
const names = companionRoster().map(e => e.name);
|
|
1693
|
+
if (!names.includes(AGENT.name)) names.push(AGENT.name);
|
|
1694
|
+
const slugs = names.map(n => agentFor(n).authorID.slice("agent.".length));
|
|
1695
|
+
const got = await cloud.lookup(slugs.map(s => `companion-${s}`));
|
|
1696
|
+
return names.map((name, i) => {
|
|
1697
|
+
const r = got[`companion-${slugs[i]}`];
|
|
1698
|
+
return r ? { name, version: cloud.field(r, "appVersion"), host: cloud.field(r, "device"),
|
|
1699
|
+
aliveAt: cloud.field(r, "aliveAt"), waking: (cloud.field(r, "pollSeconds") || 0) > 0 } : { name };
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// One line for status and workflow_state: this version, and whether it's the latest.
|
|
1704
|
+
async function versionLine() {
|
|
1705
|
+
const rel = await latestRelease();
|
|
1706
|
+
const auto = (readJSON(CP.config, {}) || {}).autoUpdate === false ? "auto-update off" : "auto-update on";
|
|
1707
|
+
if (!rel) return `${SERVER_INFO.version} (couldn't check npm for a newer one; ${auto})`;
|
|
1708
|
+
return newerVersion(rel.version, SERVER_INFO.version)
|
|
1709
|
+
? `${SERVER_INFO.version}, UPDATE AVAILABLE: ${rel.version}. Run "npx -y @opticell/workflow-companion@latest update" (${auto})`
|
|
1710
|
+
: `${SERVER_INFO.version}, the latest (${auto})`;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// How Claude Code should run the companion: @latest, so every new session
|
|
1714
|
+
// picks up the newest release. Windows needs cmd /c in front of npx.
|
|
1715
|
+
function claudeCodeCommand() {
|
|
1716
|
+
return `claude mcp add --scope user workflow -- ${process.platform === "win32" ? "cmd /c " : ""}npx -y @opticell/workflow-companion@latest`;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// `workflow-companion update`: refresh the saved copy now, say the version,
|
|
1720
|
+
// and how to keep Claude Code current.
|
|
1721
|
+
async function updateNow() {
|
|
1722
|
+
const result = await selfUpdate({ force: true });
|
|
1723
|
+
const rel = await latestRelease();
|
|
1724
|
+
console.log(`Latest release: ${rel ? rel.version : "unknown (couldn't reach npm)"}`);
|
|
1725
|
+
console.log(`Scheduled jobs' copy: ${result === "nothing scheduled" ? "none (waking and keep-alive aren't scheduled here)" : result}`);
|
|
1726
|
+
await reportPresence(true);
|
|
1727
|
+
console.log(`\nClaude Code runs whatever its registration says. To always get the latest:\n claude mcp remove workflow -s user\n ${claudeCodeCommand()}`);
|
|
1728
|
+
return 0;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// `workflow-companion auto-update on|off`.
|
|
1732
|
+
function setAutoUpdate(on) {
|
|
1733
|
+
if (!fs.existsSync(CP.config)) { console.error(signInHelp("Not signed in yet.")); return 2; }
|
|
1734
|
+
updateConfig(cfg => { cfg.autoUpdate = on; });
|
|
1735
|
+
console.log(on ? "Auto-update is on: the scheduled jobs update themselves when a new release is out."
|
|
1736
|
+
: "Auto-update is off. Update by hand with: npx -y @opticell/workflow-companion@latest update");
|
|
1437
1737
|
return 0;
|
|
1438
1738
|
}
|
|
1439
1739
|
|
|
1440
1740
|
function printConfig() {
|
|
1441
1741
|
if (CLOUD) {
|
|
1442
|
-
process.stdout.write(`Claude Code:\n
|
|
1742
|
+
process.stdout.write(`Claude Code:\n ${claudeCodeCommand()}\n\nClaude Desktop → Settings → Developer → Edit Config, add:\n${JSON.stringify({ mcpServers: { workflow: { command: "npx", args: ["-y", "@opticell/workflow-companion@latest"] } } }, null, 2)}\n`);
|
|
1443
1743
|
return;
|
|
1444
1744
|
}
|
|
1445
1745
|
const cfg = { mcpServers: { workflow: { command: "node", args: [__filename], env: { WF_BRIDGE: ROOT } } } };
|
|
@@ -1453,6 +1753,8 @@ const HELP = `WorkFlow companion ${SERVER_INFO.version}
|
|
|
1453
1753
|
workflow-companion setup sign this computer in to iCloud for WorkFlow (--name, --takeover, --no-browser)
|
|
1454
1754
|
workflow-companion status which device answers, how fresh is the snapshot
|
|
1455
1755
|
workflow-companion keepalive renew the sign-in (what the scheduled job runs)
|
|
1756
|
+
workflow-companion update update the scheduled jobs' copy now, and say how to keep Claude Code current
|
|
1757
|
+
workflow-companion auto-update on|off whether the scheduled jobs update themselves (on by default)
|
|
1456
1758
|
workflow-companion wake-on start a Claude Code session on its own when @name work arrives
|
|
1457
1759
|
(--allow-tools, --model, --workdir, --max-minutes, --claude, --skip-existing)
|
|
1458
1760
|
workflow-companion wake-off stop doing that; work waits until you ask
|
|
@@ -1468,6 +1770,8 @@ else if (argv.includes("--help") || verb === "help") process.stdout.write(HELP);
|
|
|
1468
1770
|
else if (verb === "setup") setup().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
1469
1771
|
else if (verb === "status") cloudStateText().then(t => console.log(t), e => { console.error(e.message || e); process.exitCode = 1; });
|
|
1470
1772
|
else if (verb === "keepalive") keepAlive().then(c => { process.exitCode = c; });
|
|
1773
|
+
else if (verb === "update") updateNow().then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
1774
|
+
else if (verb === "auto-update") process.exitCode = setAutoUpdate(argv[argv.indexOf("auto-update") + 1] !== "off");
|
|
1471
1775
|
else if (verb === "wake") wake().then(c => { process.exitCode = c; }, e => { logWake(`failed: ${e.message || e}`); process.exitCode = 1; });
|
|
1472
1776
|
else if (verb === "wake-on" || verb === "wake-off") wakeSetup(verb === "wake-on").then(c => { process.exitCode = c; }, e => { console.error(e.message || e); process.exitCode = 1; });
|
|
1473
1777
|
else if (verb === "signout") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opticell/workflow-companion",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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
|
}
|