@sellable/install 0.1.605 → 0.1.606-wip.phase187.20260811.15
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/container/Dockerfile +7 -7
- package/container/README.md +2 -2
- package/lib/sellable-agent/admin-runtime-reconciler.mjs +2 -2
- package/lib/sellable-agent/external-runtime-builder.mjs +1 -1
- package/lib/sellable-agent/fly-admin-image/admin-runtime.mjs +77 -5
- package/lib/sellable-agent/fly-customer-image/Dockerfile +3 -3
- package/lib/sellable-agent/fly-customer-image/customer-runtime.mjs +2 -2
- package/lib/sellable-agent/hermes-memory-snapshot.mjs +301 -64
- package/lib/sellable-agent/host-bootstrap.mjs +2 -2
- package/lib/sellable-agent/host-worker.mjs +2 -2
- package/lib/sellable-agent/profile-materializer.mjs +2 -2
- package/lib/sellable-agent/provisioning-adapter.mjs +2 -2
- package/package.json +1 -1
package/container/Dockerfile
CHANGED
|
@@ -3,13 +3,13 @@ FROM ${HERMES_IMAGE}
|
|
|
3
3
|
|
|
4
4
|
USER root
|
|
5
5
|
|
|
6
|
-
ARG INSTALLER_PACKAGE=@sellable/install@0.1.
|
|
7
|
-
ARG MCP_PACKAGE=@sellable/mcp@0.1.
|
|
6
|
+
ARG INSTALLER_PACKAGE=@sellable/install@0.1.606-wip.phase187.20260811.15
|
|
7
|
+
ARG MCP_PACKAGE=@sellable/mcp@0.1.865-wip.phase187.20260811.15
|
|
8
8
|
ARG INSTALLER_INTEGRITY
|
|
9
|
-
ARG MCP_INTEGRITY=sha512-
|
|
9
|
+
ARG MCP_INTEGRITY=sha512-eMgElGCRnQia1P9NUNuBoc3T/OKHbmDAGwurVljWK5ED+tifDYmXla3VWEmUrqImkthr+9zfJnApCqVBLZIpSA==
|
|
10
10
|
|
|
11
|
-
RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.
|
|
12
|
-
&& test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.
|
|
11
|
+
RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.606-wip.phase187.20260811.15" \
|
|
12
|
+
&& test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.865-wip.phase187.20260811.15" \
|
|
13
13
|
&& test -n "${INSTALLER_INTEGRITY}" \
|
|
14
14
|
&& test "$(npm view "${INSTALLER_PACKAGE}" dist.integrity)" = "${INSTALLER_INTEGRITY}" \
|
|
15
15
|
&& test "$(npm view "${MCP_PACKAGE}" dist.integrity)" = "${MCP_INTEGRITY}" \
|
|
@@ -23,9 +23,9 @@ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.605" \
|
|
|
23
23
|
&& mkdir -p /opt/sellable-agent/install-root /opt/sellable-agent/mcp-root \
|
|
24
24
|
&& npm install --prefix /opt/sellable-agent/install-root --omit=dev --ignore-scripts --no-audit --no-fund "${INSTALLER_PACKAGE}" \
|
|
25
25
|
&& npm install --prefix /opt/sellable-agent/mcp-root --omit=dev --ignore-scripts --no-audit --no-fund "${MCP_PACKAGE}" \
|
|
26
|
-
&& node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.
|
|
26
|
+
&& node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.606-wip.hermes-desktop.20260811160530' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.865-wip.phase187.20260811.15') throw new Error('package identity rejected');" \
|
|
27
27
|
&& node --input-type=module -e "import { buildExternalRuntimeClosure } from '/opt/sellable-agent/install-root/node_modules/@sellable/install/lib/sellable-agent/external-runtime-builder.mjs'; const built = buildExternalRuntimeClosure({ mcpPackageRoot: '/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp', mcpNodeModulesRoot: '/opt/sellable-agent/mcp-root/node_modules', hermesSourceRoot: '/opt/hermes', ownerUid: 0, ownerGid: 0 }); if (!built.closureDigest) throw new Error('runtime closure rejected');" \
|
|
28
|
-
&& node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.
|
|
28
|
+
&& node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.606-wip.phase187.20260811.15', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.865-wip.phase187.20260811.15', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
|
|
29
29
|
&& npm cache clean --force
|
|
30
30
|
|
|
31
31
|
COPY entrypoint.sh /usr/local/bin/sellable-agent-container-entrypoint
|
package/container/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
This image is a dedicated Agent worker/runtime boundary beside an existing Hermes dashboard. It never replaces or reconfigures the dashboard container.
|
|
4
4
|
|
|
5
5
|
Build from this directory after
|
|
6
|
-
`@sellable/install@0.1.
|
|
6
|
+
`@sellable/install@0.1.606-wip.phase187.20260811.15` is
|
|
7
7
|
published:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
docker build --platform linux/amd64 \
|
|
11
11
|
--build-arg INSTALLER_INTEGRITY='sha512-…' \
|
|
12
|
-
--tag sellable-agent-host:0.1.
|
|
12
|
+
--tag sellable-agent-host:0.1.606-wip.hermes-desktop.20260811160530 .
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Create a private `bootstrap/` directory owned by root with mode `0700`. It must contain `bootstrap.json`, `provider-auth`, and `slack-app`, each owned by root with mode `0600`. `bootstrap.json` contains only non-secret identities, hashes, package integrities, and the control-plane URL. The other two files contain the provider JSON and global Slack app token whose hashes are pinned by `bootstrap.json`.
|
|
@@ -20,6 +20,7 @@ import { createHash } from "node:crypto";
|
|
|
20
20
|
|
|
21
21
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
22
22
|
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-.A-Za-z0-9]+)?$/;
|
|
23
|
+
const PACKAGE_DISPOSITIONS = new Set(["changed", "reused"]);
|
|
23
24
|
const PUBLIC_ACTIONS = new Set(["INSTANTIATE", "REFRESH"]);
|
|
24
25
|
const RESTORATION_ACTIONS = new Set([
|
|
25
26
|
"START",
|
|
@@ -64,14 +65,13 @@ export function validateAdminRuntimeTuple(tuple) {
|
|
|
64
65
|
}
|
|
65
66
|
for (const name of ADMIN_RUNTIME_TUPLE_MEMBERS) {
|
|
66
67
|
const item = tuple.packages[name];
|
|
67
|
-
const expectedDisposition = name === "@sellable/mcp" ? "reused" : "changed";
|
|
68
68
|
if (
|
|
69
69
|
!item ||
|
|
70
70
|
item.name !== name ||
|
|
71
71
|
!EXACT_VERSION.test(item.version ?? "") ||
|
|
72
72
|
typeof item.integrity !== "string" ||
|
|
73
73
|
!/^sha512-[A-Za-z0-9+/]{80,}={0,2}$/.test(item.integrity) ||
|
|
74
|
-
item.disposition
|
|
74
|
+
!PACKAGE_DISPOSITIONS.has(item.disposition)
|
|
75
75
|
) {
|
|
76
76
|
return {
|
|
77
77
|
ok: false,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
const MANIFEST_VERSION = "sellable-agent-external-runtime-closure/v1";
|
|
30
30
|
const MCP_PACKAGE = "@sellable/mcp";
|
|
31
|
-
const MCP_VERSION = "0.1.
|
|
31
|
+
const MCP_VERSION = "0.1.865-wip.phase187.20260811.15";
|
|
32
32
|
const SAFE_COMPONENT = /^[A-Za-z0-9@._+-]+$/;
|
|
33
33
|
const HERMES_EXCLUDES = new Set([".git", ".playwright", "node_modules"]);
|
|
34
34
|
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
HERMES_SNAPSHOT_HOOKS,
|
|
68
68
|
HERMES_SNAPSHOT_HOOK_ALLOWLIST_BYTES,
|
|
69
69
|
applyHermesHostedBackgroundReviewPolicy,
|
|
70
|
+
ensureHermesOpportunitySequencesCron,
|
|
70
71
|
ensureHermesSnapshotCron,
|
|
71
72
|
markHermesSnapshotDirty,
|
|
72
73
|
mergeHermesState,
|
|
@@ -696,6 +697,22 @@ const ADMIN_LIVE_SKILLS_CONFIG_KEYS = Object.freeze([
|
|
|
696
697
|
"disabled",
|
|
697
698
|
"platform_disabled",
|
|
698
699
|
]);
|
|
700
|
+
function packagedAdminSkillDirectories() {
|
|
701
|
+
const skillsRoot = join(ADMIN_INSTALL_ASSET_ROOT, "mcp", "skills");
|
|
702
|
+
const directories = new Set(["sellable"]);
|
|
703
|
+
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
704
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
705
|
+
throw new Error("admin_runtime_packaged_skills_inventory_rejected");
|
|
706
|
+
}
|
|
707
|
+
directories.add(entry.name);
|
|
708
|
+
}
|
|
709
|
+
return directories;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Candidate run 31458025989 proved that a fresh profile must not classify
|
|
713
|
+
// package-backed Admin skills as operator state: doing so made both allowed
|
|
714
|
+
// SOUL restage attempts compare an empty live inventory with the sealed stage.
|
|
715
|
+
const ADMIN_SEALED_SKILL_DIRECTORIES = packagedAdminSkillDirectories();
|
|
699
716
|
|
|
700
717
|
function adminPreservedSlackToolsets(config) {
|
|
701
718
|
const platformToolsets = config?.platform_toolsets;
|
|
@@ -715,17 +732,17 @@ function adminPreservedSlackToolsets(config) {
|
|
|
715
732
|
|
|
716
733
|
function adminPreservedSkillsIdentity(profileRoot) {
|
|
717
734
|
if (!existsSync(profileRoot)) return null;
|
|
718
|
-
const entries = [];
|
|
719
735
|
const skillsRoot = join(profileRoot, "skills");
|
|
720
736
|
const configPath = join(profileRoot, "config.yaml");
|
|
721
737
|
// Admin settings are delivered into .sellable-admin before the Hermes
|
|
722
738
|
// profile is staged. That settings-only directory is not an existing live
|
|
723
739
|
// profile and therefore has no skills state to preserve.
|
|
724
740
|
if (!existsSync(skillsRoot) && !existsSync(configPath)) return null;
|
|
741
|
+
const entries = [];
|
|
725
742
|
const walk = (directory, relativePath = "") => {
|
|
726
743
|
for (const name of readdirSync(directory).sort()) {
|
|
727
744
|
const nextRelative = relativePath ? `${relativePath}/${name}` : name;
|
|
728
|
-
if (nextRelative
|
|
745
|
+
if (ADMIN_SEALED_SKILL_DIRECTORIES.has(nextRelative)) continue;
|
|
729
746
|
const path = join(directory, name);
|
|
730
747
|
const stat = lstatSync(path);
|
|
731
748
|
if (stat.isSymbolicLink()) {
|
|
@@ -795,6 +812,45 @@ function mergeAdminLiveSkillsConfig(sourceRoot, targetRoot) {
|
|
|
795
812
|
atomicPrivateFile(targetPath, targetDocument.toString());
|
|
796
813
|
}
|
|
797
814
|
|
|
815
|
+
function installWritableAdminSlackChannelPolicy(stageRoot, slack) {
|
|
816
|
+
const envPath = join(stageRoot, ".env");
|
|
817
|
+
if (!existsSync(envPath)) {
|
|
818
|
+
throw new Error("admin_runtime_slack_environment_rejected");
|
|
819
|
+
}
|
|
820
|
+
const stat = lstatSync(envPath);
|
|
821
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
822
|
+
throw new Error("admin_runtime_slack_environment_rejected");
|
|
823
|
+
}
|
|
824
|
+
if (
|
|
825
|
+
!Array.isArray(slack?.selectedChannelIds) ||
|
|
826
|
+
slack.selectedChannelIds.length < 1 ||
|
|
827
|
+
!slack.selectedChannelIds.includes(slack.primaryChannel?.id)
|
|
828
|
+
) {
|
|
829
|
+
throw new Error("admin_runtime_slack_contract_rejected");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
let allowedPolicyCount = 0;
|
|
833
|
+
let readOnlyPolicyCount = 0;
|
|
834
|
+
const updated = readFileSync(envPath, "utf8")
|
|
835
|
+
.split("\n")
|
|
836
|
+
.map((line) => {
|
|
837
|
+
if (line.startsWith("SLACK_ALLOWED_CHANNELS=")) {
|
|
838
|
+
allowedPolicyCount += 1;
|
|
839
|
+
return `SLACK_ALLOWED_CHANNELS=${slack.selectedChannelIds.join(",")}`;
|
|
840
|
+
}
|
|
841
|
+
if (line.startsWith("SLACK_READ_ONLY_CHANNELS=")) {
|
|
842
|
+
readOnlyPolicyCount += 1;
|
|
843
|
+
return "SLACK_READ_ONLY_CHANNELS=";
|
|
844
|
+
}
|
|
845
|
+
return line;
|
|
846
|
+
})
|
|
847
|
+
.join("\n");
|
|
848
|
+
if (allowedPolicyCount !== 1 || readOnlyPolicyCount !== 1) {
|
|
849
|
+
throw new Error("admin_runtime_slack_environment_rejected");
|
|
850
|
+
}
|
|
851
|
+
atomicPrivateFile(envPath, updated);
|
|
852
|
+
}
|
|
853
|
+
|
|
798
854
|
async function materializeAdminProfile({
|
|
799
855
|
profileContract,
|
|
800
856
|
settingsRoot,
|
|
@@ -899,6 +955,7 @@ async function materializeAdminProfile({
|
|
|
899
955
|
return { ok: true, ...proof };
|
|
900
956
|
},
|
|
901
957
|
});
|
|
958
|
+
if (slack) installWritableAdminSlackChannelPolicy(stageRoot, slack);
|
|
902
959
|
installAdminModelConfig(stageRoot, modelConfig);
|
|
903
960
|
installAdminHermesMemoryHook(stageRoot);
|
|
904
961
|
stageAdminRuntimeTree(stageRoot);
|
|
@@ -992,12 +1049,22 @@ async function materializeAdminProfile({
|
|
|
992
1049
|
hermesCli: HERMES,
|
|
993
1050
|
});
|
|
994
1051
|
if (!memoryCron.ok) throw new Error(memoryCron.code);
|
|
1052
|
+
const opportunitySequencesCron = await ensureHermesOpportunitySequencesCron({
|
|
1053
|
+
profileId: profile,
|
|
1054
|
+
profileRoot: profileHome,
|
|
1055
|
+
hermesRoot: DATA_ROOT,
|
|
1056
|
+
statePath: layout.statePath,
|
|
1057
|
+
});
|
|
1058
|
+
if (!opportunitySequencesCron.ok) {
|
|
1059
|
+
throw new Error(opportunitySequencesCron.code);
|
|
1060
|
+
}
|
|
995
1061
|
return {
|
|
996
1062
|
stage,
|
|
997
1063
|
promotion: promoted.promotion,
|
|
998
1064
|
profile,
|
|
999
1065
|
profileHome,
|
|
1000
1066
|
adminHome,
|
|
1067
|
+
opportunitySequencesCron,
|
|
1001
1068
|
observedSoulRawSha256: promoted.observedSoulRawSha256,
|
|
1002
1069
|
servers: [serverProofs.sellable, serverProofs["sellable-admin"]].map(
|
|
1003
1070
|
({ tools: _tools, ...proof }) => proof
|
|
@@ -1644,9 +1711,10 @@ function nativeSlackProfileInput(claim, installed) {
|
|
|
1644
1711
|
id: primary.channelId,
|
|
1645
1712
|
name: primary.channelName ?? primary.channelId,
|
|
1646
1713
|
},
|
|
1647
|
-
|
|
1648
|
-
.
|
|
1649
|
-
|
|
1714
|
+
selectedChannelIds: [
|
|
1715
|
+
...new Set(bindings.map((binding) => binding.channelId)),
|
|
1716
|
+
].sort(),
|
|
1717
|
+
secondaryReadChannelIds: [],
|
|
1650
1718
|
allowedSlackUserIds: [...slack.memberAllowlist],
|
|
1651
1719
|
slackApp: {
|
|
1652
1720
|
appId: slack.appId,
|
|
@@ -2450,6 +2518,7 @@ export async function runAdminCanary() {
|
|
|
2450
2518
|
}
|
|
2451
2519
|
const fixtureWorkspaceId = "phase158-canary-workspace";
|
|
2452
2520
|
const fixtureAgentId = "phase158-canary-admin-agent";
|
|
2521
|
+
const fixtureNotificationSlackUserId = "U187CANARY";
|
|
2453
2522
|
const fixtureFiles = {
|
|
2454
2523
|
".env": "PHASE158_ADMIN_CANARY=nonsecret\n",
|
|
2455
2524
|
"settings.json": `${JSON.stringify({
|
|
@@ -2462,6 +2531,9 @@ export async function runAdminCanary() {
|
|
|
2462
2531
|
})}\n`,
|
|
2463
2532
|
"config-state.json": `${JSON.stringify({
|
|
2464
2533
|
sellable: { workspaceIds: [], workspaceNames: {} },
|
|
2534
|
+
opportunitySequences: {
|
|
2535
|
+
notificationSlackUserId: fixtureNotificationSlackUserId,
|
|
2536
|
+
},
|
|
2465
2537
|
})}\n`,
|
|
2466
2538
|
};
|
|
2467
2539
|
for (const [name, value] of Object.entries(fixtureFiles)) {
|
|
@@ -4,9 +4,9 @@ FROM ${HERMES_IMAGE}
|
|
|
4
4
|
|
|
5
5
|
USER root
|
|
6
6
|
|
|
7
|
-
ARG MCP_PACKAGE=@sellable/mcp@0.1.
|
|
8
|
-
ARG MCP_INTEGRITY=sha512-
|
|
9
|
-
ARG INSTALLER_PACKAGE=@sellable/install@0.1.
|
|
7
|
+
ARG MCP_PACKAGE=@sellable/mcp@0.1.865-wip.phase187.20260811.15
|
|
8
|
+
ARG MCP_INTEGRITY=sha512-eMgElGCRnQia1P9NUNuBoc3T/OKHbmDAGwurVljWK5ED+tifDYmXla3VWEmUrqImkthr+9zfJnApCqVBLZIpSA==
|
|
9
|
+
ARG INSTALLER_PACKAGE=@sellable/install@0.1.606-wip.phase187.20260811.15
|
|
10
10
|
ARG ADMIN_MCP_PACKAGE=@sellable/admin-mcp@0.1.186-wip.phase158.20260802034727
|
|
11
11
|
ARG ADMIN_MCP_INTEGRITY=sha512-qYNew5wd2IfgVcp1UYOG9QvpF/8Mx19YJ5HQtMKzvSfUf3evePjtaKsoXLf2C/GUJ5QlLlHJo84VWOI3V01+9A==
|
|
12
12
|
ARG TUS_JS_CLIENT_VERSION=4.3.1
|
|
@@ -95,9 +95,9 @@ const RUNTIME_GENERATION = Number(
|
|
|
95
95
|
const BOOT_SESSION_ID = randomUUID();
|
|
96
96
|
const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.20.0";
|
|
97
97
|
const INSTALLER_PACKAGE =
|
|
98
|
-
process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.
|
|
98
|
+
process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.606-wip.phase187.20260811.15";
|
|
99
99
|
const MCP_PACKAGE =
|
|
100
|
-
process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.
|
|
100
|
+
process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.865-wip.phase187.20260811.15";
|
|
101
101
|
|
|
102
102
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
103
103
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -55,24 +55,36 @@ export const HERMES_SNAPSHOT_CRON = Object.freeze({
|
|
|
55
55
|
installedScript: "scripts/hermes-memory-reconcile.sh",
|
|
56
56
|
noAgent: true,
|
|
57
57
|
});
|
|
58
|
+
export const HERMES_OPPORTUNITY_SEQUENCES_CRON = Object.freeze({
|
|
59
|
+
name: "sellable-opportunity-sequences",
|
|
60
|
+
schedule: "every 30m",
|
|
61
|
+
deliver: "slack",
|
|
62
|
+
skills: Object.freeze(["sellable-admin-opportunity-sequences"]),
|
|
63
|
+
enabledToolsets: Object.freeze(["safe", "sellable", "mcp"]),
|
|
64
|
+
noAgent: false,
|
|
65
|
+
});
|
|
66
|
+
export const HERMES_OPPORTUNITY_SEQUENCES_JOURNAL_ROOT =
|
|
67
|
+
"/opt/data/.sellable-agent/admin-runtime/opportunity-sequences";
|
|
58
68
|
const LEGACY_HERMES_SNAPSHOT_SCRIPT = "scripts/hermes-memory-reconcile.sh";
|
|
59
69
|
|
|
60
70
|
const HERMES = "/opt/hermes/.venv/bin/hermes";
|
|
71
|
+
const HERMES_PYTHON = "/opt/hermes/.venv/bin/python";
|
|
61
72
|
const VERSION = "0.20.0";
|
|
62
73
|
const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
63
74
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
64
75
|
const MAX_BYTES = 128 * 1024 * 1024;
|
|
65
|
-
// Portable snapshots contain
|
|
66
|
-
// Hermes
|
|
67
|
-
//
|
|
68
|
-
//
|
|
76
|
+
// Portable snapshots contain user-authored/customizable state plus the compact
|
|
77
|
+
// Hermes session database and routing records required to continue an existing
|
|
78
|
+
// Slack thread after a Machine/volume migration. If native Hermes cannot
|
|
79
|
+
// package that bounded set within two minutes, abort the helper and let the
|
|
80
|
+
// persistent retry scheduler back off instead of contending with the live
|
|
81
|
+
// Slack gateway for a full ten minutes.
|
|
69
82
|
const NATIVE_COMMAND_TIMEOUT_MS = 120_000;
|
|
70
83
|
const TRANSIENT_PROFILE_ROOTS = new Set([".cache"]);
|
|
71
|
-
// The encrypted Fly volume remains the authoritative full profile
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// customizations a user or agent can intentionally change.
|
|
84
|
+
// The encrypted Fly volume remains the authoritative full profile. The
|
|
85
|
+
// portable disaster-recovery archive also carries state.db and sessions so a
|
|
86
|
+
// replacement volume retains chat history and Slack thread routing. Caches,
|
|
87
|
+
// dependency trees, secrets, and live logs remain excluded.
|
|
76
88
|
export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
|
|
77
89
|
"AGENTS.md",
|
|
78
90
|
"SOUL.md",
|
|
@@ -88,8 +100,10 @@ export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
|
|
|
88
100
|
"projects.db",
|
|
89
101
|
"sandboxes",
|
|
90
102
|
"scripts",
|
|
103
|
+
"sessions",
|
|
91
104
|
"skins",
|
|
92
105
|
"skills",
|
|
106
|
+
"state.db",
|
|
93
107
|
"workspace",
|
|
94
108
|
]);
|
|
95
109
|
export const HERMES_SNAPSHOT_RETRY_BACKOFF_MS = Object.freeze([
|
|
@@ -259,6 +273,68 @@ function cronJobs(profileRoot) {
|
|
|
259
273
|
return jobs;
|
|
260
274
|
}
|
|
261
275
|
|
|
276
|
+
const sameJson = (left, right) =>
|
|
277
|
+
JSON.stringify(left) === JSON.stringify(right);
|
|
278
|
+
|
|
279
|
+
export async function ensureHermesNamedCron({
|
|
280
|
+
profileRoot,
|
|
281
|
+
name,
|
|
282
|
+
isExpected,
|
|
283
|
+
isUpdatable = () => true,
|
|
284
|
+
create,
|
|
285
|
+
update,
|
|
286
|
+
codePrefix = "hermes_named_cron",
|
|
287
|
+
}) {
|
|
288
|
+
if (
|
|
289
|
+
!isAbsolute(profileRoot) ||
|
|
290
|
+
!ID.test(name ?? "") ||
|
|
291
|
+
typeof isExpected !== "function" ||
|
|
292
|
+
typeof create !== "function" ||
|
|
293
|
+
typeof update !== "function"
|
|
294
|
+
) {
|
|
295
|
+
return failed(`${codePrefix}_identity_rejected`);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const before = cronJobs(profileRoot);
|
|
299
|
+
const named = before.filter((job) => job?.name === name);
|
|
300
|
+
if (named.length > 1 || (named[0] && !isUpdatable(named[0]))) {
|
|
301
|
+
return failed(`${codePrefix}_inventory_rejected`);
|
|
302
|
+
}
|
|
303
|
+
if (named[0] && isExpected(named[0])) {
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
status: "REUSED",
|
|
307
|
+
jobId: named[0].id,
|
|
308
|
+
job: named[0],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const siblings = before.filter((job) => job?.name !== name);
|
|
312
|
+
const mutation = named[0] ? await update(named[0]) : await create();
|
|
313
|
+
if (childFailed(mutation)) {
|
|
314
|
+
return failed(`${codePrefix}_${named[0] ? "update" : "create"}_failed`);
|
|
315
|
+
}
|
|
316
|
+
const after = cronJobs(profileRoot);
|
|
317
|
+
const readback = after.filter((job) => job?.name === name);
|
|
318
|
+
const afterSiblings = after.filter((job) => job?.name !== name);
|
|
319
|
+
if (!sameJson(afterSiblings, siblings)) {
|
|
320
|
+
return failed(`${codePrefix}_sibling_readback_rejected`);
|
|
321
|
+
}
|
|
322
|
+
if (readback.length !== 1 || !isExpected(readback[0])) {
|
|
323
|
+
return failed(
|
|
324
|
+
`${codePrefix}_${named[0] ? "update_readback" : "readback"}_rejected`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
status: named[0] ? "UPDATED" : "CREATED",
|
|
330
|
+
jobId: readback[0].id,
|
|
331
|
+
job: readback[0],
|
|
332
|
+
};
|
|
333
|
+
} catch {
|
|
334
|
+
return failed(`${codePrefix}_install_failed`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
262
338
|
function isCron(job) {
|
|
263
339
|
return Boolean(
|
|
264
340
|
job?.id &&
|
|
@@ -430,25 +506,23 @@ export async function ensureHermesSnapshotCron({
|
|
|
430
506
|
readFileSync(reconcileTemplatePath),
|
|
431
507
|
0o700
|
|
432
508
|
);
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
if (before[0]) {
|
|
443
|
-
if (isLegacyCron(before[0])) {
|
|
444
|
-
const migrated = await childRunner(runChild)(
|
|
509
|
+
const result = await ensureHermesNamedCron({
|
|
510
|
+
profileRoot,
|
|
511
|
+
name: HERMES_SNAPSHOT_CRON.name,
|
|
512
|
+
isExpected: isCron,
|
|
513
|
+
isUpdatable: (job) => isCron(job) || isLegacyCron(job),
|
|
514
|
+
codePrefix: "hermes_snapshot_cron",
|
|
515
|
+
create: () =>
|
|
516
|
+
childRunner(runChild)(
|
|
445
517
|
invocation(
|
|
446
518
|
[
|
|
447
519
|
"--profile",
|
|
448
520
|
profileId,
|
|
449
521
|
"cron",
|
|
450
|
-
"
|
|
451
|
-
|
|
522
|
+
"create",
|
|
523
|
+
HERMES_SNAPSHOT_CRON.schedule,
|
|
524
|
+
"--name",
|
|
525
|
+
HERMES_SNAPSHOT_CRON.name,
|
|
452
526
|
"--script",
|
|
453
527
|
HERMES_SNAPSHOT_CRON.script,
|
|
454
528
|
"--no-agent",
|
|
@@ -456,53 +530,216 @@ export async function ensureHermesSnapshotCron({
|
|
|
456
530
|
hermesRoot,
|
|
457
531
|
hermesCli
|
|
458
532
|
)
|
|
459
|
-
)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
);
|
|
466
|
-
return afterMigration.length === 1 && isCron(afterMigration[0])
|
|
467
|
-
? {
|
|
468
|
-
ok: true,
|
|
469
|
-
status: "MIGRATED",
|
|
470
|
-
jobId: afterMigration[0].id,
|
|
533
|
+
),
|
|
534
|
+
update: (job) =>
|
|
535
|
+
childRunner(runChild)(
|
|
536
|
+
invocation(
|
|
537
|
+
[
|
|
538
|
+
"--profile",
|
|
471
539
|
profileId,
|
|
472
|
-
|
|
473
|
-
|
|
540
|
+
"cron",
|
|
541
|
+
"edit",
|
|
542
|
+
job.id,
|
|
543
|
+
"--script",
|
|
544
|
+
HERMES_SNAPSHOT_CRON.script,
|
|
545
|
+
"--no-agent",
|
|
546
|
+
],
|
|
547
|
+
hermesRoot,
|
|
548
|
+
hermesCli
|
|
549
|
+
)
|
|
550
|
+
),
|
|
551
|
+
});
|
|
552
|
+
if (!result.ok) {
|
|
553
|
+
if (result.code === "hermes_snapshot_cron_update_failed") {
|
|
554
|
+
return failed("hermes_snapshot_cron_migrate_failed");
|
|
555
|
+
}
|
|
556
|
+
if (result.code === "hermes_snapshot_cron_update_readback_rejected") {
|
|
557
|
+
return failed("hermes_snapshot_cron_migrate_readback_rejected");
|
|
474
558
|
}
|
|
475
|
-
return
|
|
559
|
+
return result;
|
|
476
560
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
status: result.status === "UPDATED" ? "MIGRATED" : result.status,
|
|
564
|
+
jobId: result.jobId,
|
|
565
|
+
profileId,
|
|
566
|
+
};
|
|
567
|
+
} catch {
|
|
568
|
+
return failed("hermes_snapshot_cron_install_failed");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const OPPORTUNITY_SEQUENCE_ASSETS = Object.freeze([
|
|
573
|
+
"SKILL.md",
|
|
574
|
+
"references/outbound-multichannel-v1.md",
|
|
575
|
+
"references/positive-reply-to-agreed-meeting-v1.md",
|
|
576
|
+
]);
|
|
577
|
+
const SLACK_USER_ID = /^U[A-Z0-9]{8,20}$/;
|
|
578
|
+
const OPPORTUNITY_SEQUENCE_CRON_PYTHON = [
|
|
579
|
+
"import json,sys",
|
|
580
|
+
"from cron.jobs import create_job, update_job",
|
|
581
|
+
"value=json.loads(sys.argv[1])",
|
|
582
|
+
"job_id=value.pop('jobId', None)",
|
|
583
|
+
"enabled=value.pop('enabled')",
|
|
584
|
+
"result=update_job(job_id, {**value, 'enabled': enabled}) if job_id else create_job(**value)",
|
|
585
|
+
"print(json.dumps({'id': result.get('id') if result else None}))",
|
|
586
|
+
].join("\n");
|
|
587
|
+
|
|
588
|
+
function opportunitySequenceIdentity(profileRoot) {
|
|
589
|
+
const root = join(
|
|
590
|
+
profileRoot,
|
|
591
|
+
"skills",
|
|
592
|
+
"sellable-admin",
|
|
593
|
+
HERMES_OPPORTUNITY_SEQUENCES_CRON.skills[0]
|
|
594
|
+
);
|
|
595
|
+
return Object.fromEntries(
|
|
596
|
+
OPPORTUNITY_SEQUENCE_ASSETS.map((relative) => {
|
|
597
|
+
const path = join(root, relative);
|
|
598
|
+
regularFile(path);
|
|
599
|
+
return [relative, sha256(readFileSync(path))];
|
|
600
|
+
})
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function opportunitySequencePrompt({
|
|
605
|
+
notificationSlackUserId,
|
|
606
|
+
journalRoot,
|
|
607
|
+
hashes,
|
|
608
|
+
}) {
|
|
609
|
+
return [
|
|
610
|
+
"Run the installed opportunity-sequences skill for one bounded page.",
|
|
611
|
+
`Use the private journal at ${journalRoot}.`,
|
|
612
|
+
`Require these installed asset hashes: ${JSON.stringify(hashes)}.`,
|
|
613
|
+
`End with a compact native Slack summary addressed to <@${notificationSlackUserId}>.`,
|
|
614
|
+
"Never send prospect outreach; never claim exactly-once delivery; never use a direct Slack or Lightfield transport outside the installed skill and Product MCP.",
|
|
615
|
+
].join(" ");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function ensureHermesOpportunitySequencesCron({
|
|
619
|
+
profileId,
|
|
620
|
+
profileRoot,
|
|
621
|
+
hermesRoot,
|
|
622
|
+
statePath,
|
|
623
|
+
journalRoot = HERMES_OPPORTUNITY_SEQUENCES_JOURNAL_ROOT,
|
|
624
|
+
hermesPython = HERMES_PYTHON,
|
|
625
|
+
runChild,
|
|
626
|
+
}) {
|
|
627
|
+
if (
|
|
628
|
+
!validProfilePath(profileId, profileRoot, hermesRoot) ||
|
|
629
|
+
!existsSync(profileRoot) ||
|
|
630
|
+
!isAbsolute(statePath) ||
|
|
631
|
+
!isAbsolute(journalRoot)
|
|
632
|
+
) {
|
|
633
|
+
return failed("hermes_opportunity_sequences_cron_identity_rejected");
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const stateStat = regularFile(statePath);
|
|
637
|
+
if ((stateStat.mode & 0o077) !== 0) {
|
|
638
|
+
return failed("hermes_opportunity_sequences_cron_state_mode_rejected");
|
|
639
|
+
}
|
|
640
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
641
|
+
const notificationSlackUserId =
|
|
642
|
+
state?.opportunitySequences?.notificationSlackUserId;
|
|
643
|
+
if (!SLACK_USER_ID.test(notificationSlackUserId ?? "")) {
|
|
644
|
+
return failed("hermes_opportunity_sequences_cron_notification_rejected");
|
|
645
|
+
}
|
|
646
|
+
mkdirSync(journalRoot, { recursive: true, mode: 0o700 });
|
|
647
|
+
chmodSync(journalRoot, 0o700);
|
|
648
|
+
const journalStat = lstatSync(journalRoot);
|
|
649
|
+
if (
|
|
650
|
+
journalStat.isSymbolicLink() ||
|
|
651
|
+
!journalStat.isDirectory() ||
|
|
652
|
+
(journalStat.mode & 0o077) !== 0
|
|
653
|
+
) {
|
|
654
|
+
return failed("hermes_opportunity_sequences_cron_journal_rejected");
|
|
655
|
+
}
|
|
656
|
+
const hashes = opportunitySequenceIdentity(profileRoot);
|
|
657
|
+
const prompt = opportunitySequencePrompt({
|
|
658
|
+
notificationSlackUserId,
|
|
659
|
+
journalRoot,
|
|
660
|
+
hashes,
|
|
661
|
+
});
|
|
662
|
+
const expected = (job) =>
|
|
663
|
+
Boolean(
|
|
664
|
+
job?.id &&
|
|
665
|
+
job.name === HERMES_OPPORTUNITY_SEQUENCES_CRON.name &&
|
|
666
|
+
job.prompt === prompt &&
|
|
667
|
+
job.no_agent === false &&
|
|
668
|
+
job.enabled === true &&
|
|
669
|
+
job.deliver === HERMES_OPPORTUNITY_SEQUENCES_CRON.deliver &&
|
|
670
|
+
sameJson(job.skills, HERMES_OPPORTUNITY_SEQUENCES_CRON.skills) &&
|
|
671
|
+
sameJson(
|
|
672
|
+
job.enabled_toolsets,
|
|
673
|
+
HERMES_OPPORTUNITY_SEQUENCES_CRON.enabledToolsets
|
|
674
|
+
) &&
|
|
675
|
+
job.schedule?.kind === "interval" &&
|
|
676
|
+
job.schedule?.minutes === 30
|
|
677
|
+
);
|
|
678
|
+
const mutate = (jobId = null) => {
|
|
679
|
+
const spec = {
|
|
680
|
+
prompt,
|
|
681
|
+
schedule: HERMES_OPPORTUNITY_SEQUENCES_CRON.schedule,
|
|
682
|
+
name: HERMES_OPPORTUNITY_SEQUENCES_CRON.name,
|
|
683
|
+
deliver: HERMES_OPPORTUNITY_SEQUENCES_CRON.deliver,
|
|
684
|
+
skills: [...HERMES_OPPORTUNITY_SEQUENCES_CRON.skills],
|
|
685
|
+
enabled_toolsets: [
|
|
686
|
+
...HERMES_OPPORTUNITY_SEQUENCES_CRON.enabledToolsets,
|
|
490
687
|
],
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
688
|
+
no_agent: false,
|
|
689
|
+
enabled: true,
|
|
690
|
+
...(jobId ? { jobId } : {}),
|
|
691
|
+
};
|
|
692
|
+
return childRunner(runChild)({
|
|
693
|
+
command: hermesPython,
|
|
694
|
+
args: ["-c", OPPORTUNITY_SEQUENCE_CRON_PYTHON, JSON.stringify(spec)],
|
|
695
|
+
env: {
|
|
696
|
+
...process.env,
|
|
697
|
+
HOME: hermesRoot,
|
|
698
|
+
HERMES_HOME: profileRoot,
|
|
699
|
+
HERMES_PROFILE: profileId,
|
|
700
|
+
NO_COLOR: "1",
|
|
701
|
+
},
|
|
702
|
+
shell: false,
|
|
703
|
+
timeoutMs: NATIVE_COMMAND_TIMEOUT_MS,
|
|
704
|
+
maxOutputBytes: 64 * 1024,
|
|
705
|
+
});
|
|
706
|
+
};
|
|
707
|
+
const result = await ensureHermesNamedCron({
|
|
708
|
+
profileRoot,
|
|
709
|
+
name: HERMES_OPPORTUNITY_SEQUENCES_CRON.name,
|
|
710
|
+
isExpected: expected,
|
|
711
|
+
create: () => mutate(),
|
|
712
|
+
update: (job) => mutate(job.id),
|
|
713
|
+
codePrefix: "hermes_opportunity_sequences_cron",
|
|
714
|
+
});
|
|
715
|
+
if (!result.ok) return result;
|
|
716
|
+
const jobsMode =
|
|
717
|
+
lstatSync(join(profileRoot, "cron", "jobs.json")).mode & 0o777;
|
|
718
|
+
if ((jobsMode & 0o077) !== 0) {
|
|
719
|
+
return failed("hermes_opportunity_sequences_cron_jobs_mode_rejected");
|
|
497
720
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
:
|
|
721
|
+
return {
|
|
722
|
+
ok: true,
|
|
723
|
+
status: result.status,
|
|
724
|
+
jobId: result.jobId,
|
|
725
|
+
name: result.job.name,
|
|
726
|
+
prompt: result.job.prompt,
|
|
727
|
+
schedule: result.job.schedule,
|
|
728
|
+
profileId,
|
|
729
|
+
enabledToolsets: result.job.enabled_toolsets,
|
|
730
|
+
skills: result.job.skills,
|
|
731
|
+
assetHashes: hashes,
|
|
732
|
+
notificationSlackUserIdSource:
|
|
733
|
+
"config-state.json:opportunitySequences.notificationSlackUserId",
|
|
734
|
+
journalRoot,
|
|
735
|
+
modes: {
|
|
736
|
+
state: stateStat.mode & 0o777,
|
|
737
|
+
journal: journalStat.mode & 0o777,
|
|
738
|
+
jobs: jobsMode,
|
|
739
|
+
},
|
|
740
|
+
};
|
|
504
741
|
} catch {
|
|
505
|
-
return failed("
|
|
742
|
+
return failed("hermes_opportunity_sequences_cron_install_failed");
|
|
506
743
|
}
|
|
507
744
|
}
|
|
508
745
|
|
|
@@ -38,8 +38,8 @@ import { installAgentHostServices } from "./service-installer.mjs";
|
|
|
38
38
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
39
39
|
const VERSION = "sellable-agent-host-bootstrap/v1";
|
|
40
40
|
const RECEIPT_VERSION = "sellable-agent-host-registration/v1";
|
|
41
|
-
const INSTALLER_PACKAGE = "@sellable/install@0.1.
|
|
42
|
-
const MCP_PACKAGE = "@sellable/mcp@0.1.
|
|
41
|
+
const INSTALLER_PACKAGE = "@sellable/install@0.1.606-wip.phase187.20260811.15";
|
|
42
|
+
const MCP_PACKAGE = "@sellable/mcp@0.1.865-wip.phase187.20260811.15";
|
|
43
43
|
const HERMES_VERSION = "0.18.0";
|
|
44
44
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
45
45
|
const SHA256_16 = /^[a-f0-9]{16}$/;
|
|
@@ -1546,8 +1546,8 @@ export function compileClaimToProfileDesired(activeClaim, config) {
|
|
|
1546
1546
|
]) ||
|
|
1547
1547
|
pinned.hermesCli !== "hermes" ||
|
|
1548
1548
|
pinned.hermesVersion !== "0.18.0" ||
|
|
1549
|
-
pinned.installerPackage !== "@sellable/install@0.1.
|
|
1550
|
-
pinned.mcpPackage !== "@sellable/mcp@0.1.
|
|
1549
|
+
pinned.installerPackage !== "@sellable/install@0.1.606-wip.phase187.20260811.15" ||
|
|
1550
|
+
pinned.mcpPackage !== "@sellable/mcp@0.1.865-wip.phase187.20260811.15" ||
|
|
1551
1551
|
!Array.isArray(policy.toolInclude) ||
|
|
1552
1552
|
policy.toolInclude.length === 0 ||
|
|
1553
1553
|
!Number.isSafeInteger(slack.generation) ||
|
|
@@ -278,8 +278,8 @@ function validateDesired(desired) {
|
|
|
278
278
|
desired.serviceCredentialGeneration < 1 ||
|
|
279
279
|
desired.hermesCli !== "hermes" ||
|
|
280
280
|
desired.hermesVersion !== "0.18.0" ||
|
|
281
|
-
desired.installerPackage !== "@sellable/install@0.1.
|
|
282
|
-
desired.mcpPackage !== "@sellable/mcp@0.1.
|
|
281
|
+
desired.installerPackage !== "@sellable/install@0.1.606-wip.phase187.20260811.15" ||
|
|
282
|
+
desired.mcpPackage !== "@sellable/mcp@0.1.865-wip.phase187.20260811.15" ||
|
|
283
283
|
!Array.isArray(desired.toolInclude) ||
|
|
284
284
|
desired.toolInclude.length === 0 ||
|
|
285
285
|
desired.toolInclude.some((tool) => !/^[a-z][a-z0-9_]{0,127}$/.test(tool)) ||
|
|
@@ -25,8 +25,8 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import { deriveContainedProfileId } from "./profile-materializer.mjs";
|
|
26
26
|
|
|
27
27
|
export const PROVISIONING_ACTION = "PROVISION_HERMES_PROFILE";
|
|
28
|
-
export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.
|
|
29
|
-
export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.
|
|
28
|
+
export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.606-wip.phase187.20260811.15";
|
|
29
|
+
export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.865-wip.phase187.20260811.15";
|
|
30
30
|
const PINNED_INSTALL_VERSION = PINNED_INSTALL_PACKAGE.slice(
|
|
31
31
|
PINNED_INSTALL_PACKAGE.lastIndexOf("@") + 1
|
|
32
32
|
);
|